bliss_controller.py 17.4 KB
Newer Older
Perceval Guillou's avatar
Perceval Guillou committed
1
2
3
4
5
6
7
# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
# Copyright (c) 2015-2020 Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.

8
from importlib import import_module
Perceval Guillou's avatar
Perceval Guillou committed
9
10
from bliss.common.protocols import CounterContainer
from bliss.common.utils import autocomplete_property
11
from bliss.config.static import ConfigReference, ConfigNode, ConfigList
Perceval Guillou's avatar
Perceval Guillou committed
12
13


14
15
def find_sub_names_config(config, selection=None, level=0, parent_key=None):
    """ Recursively search in a config the sub-sections where the key 'name' is found. 
Perceval Guillou's avatar
Perceval Guillou committed
16

17
18
19
        Returns a dict of tuples (sub_config, parent_key) indexed by level (0 is the top level).
            - sub_config: the sub-config containing the 'name' key
            - parent_key: key under which the sub_config was found (None for level 0)
Perceval Guillou's avatar
Perceval Guillou committed
20

21
22
23
24
25
26
        args:
            config: the config that should be explored
            selection: a list containing the info of the subnames already found (for recursion)
            level: an integer describing at which level the subname was found (level=0 is the top/upper level) (for recursion)
            parent_key: key under which the sub_config was found (None for level 0) (for recursion)
    """
27

28
    assert isinstance(config, (ConfigNode, dict))
29

30
31
    if selection is None:
        selection = {}
Perceval Guillou's avatar
Perceval Guillou committed
32

33
34
    if selection.get(level) is None:
        selection[level] = []
Perceval Guillou's avatar
Perceval Guillou committed
35

36
37
38
39
    if isinstance(config, ConfigNode):
        name = config.raw_get("name")
    else:
        name = config.get("name")
Perceval Guillou's avatar
Perceval Guillou committed
40

41
42
    if name is not None:
        selection[level].append((config, parent_key))
Perceval Guillou's avatar
Perceval Guillou committed
43

44
45
46
47
48
49
    if isinstance(config, ConfigNode):
        cfg_items = (
            config.raw_items()
        )  # !!! raw_items to avoid cyclic import while resloving reference !!!
    else:
        cfg_items = config.items()
Perceval Guillou's avatar
Perceval Guillou committed
50

51
52
53
    for k, v in cfg_items:
        if isinstance(v, (ConfigNode, dict)):
            find_sub_names_config(v, selection, level + 1, k)
Perceval Guillou's avatar
Perceval Guillou committed
54

55
56
57
58
        elif isinstance(v, (ConfigList, list)):
            for i in v:
                if isinstance(i, (ConfigNode, dict)):
                    find_sub_names_config(i, selection, level + 1, k)
Perceval Guillou's avatar
Perceval Guillou committed
59

60
    return selection
Perceval Guillou's avatar
Perceval Guillou committed
61
62


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def from_config_dict(ctrl_class, cfg_dict):
    """ Helper to instanciate a BlissController object from a configuration dictionary """
    if not BlissController in ctrl_class.mro():
        raise TypeError(f"{ctrl_class} is not a BlissController class")
    bctrl = ctrl_class(cfg_dict)
    bctrl._controller_init()
    return bctrl


class BlissController(CounterContainer):
    """
        BlissController base class is made for the implementation of all Bliss controllers.
        It is designed to ease the management of sub-objects that depend on a shared controller.

        Sub-objects are declared in the yml configuration of the controller under dedicated sub-sections.
        A sub-object is considered as a subitem if it has a name (key 'name' in a sub-section of the config).
        Usually subitems are counters and axes but could be anything else (known by the controller).

        The BlissController has properties @counters and @axes to retrieve subitems that can be identified
        as counters or axes.

        
        # --- Plugin ---

        BlissController objects are created from the yml config using the bliss_controller plugin.
        Any subitem with a name can be imported in a Bliss session with config.get('name').
        The plugin ensures that the controller and subitems are only created once.
        The bliss controller itself can have a name (optional) and can be imported in the session.

        The plugin resolves dependencies between the BlissController and its subitems.
        It looks for the top 'class' key in the config to instantiate the BlissController.
        While importing any subitem in the session, the bliss controller is instantiated first (if not alive already).

        The effective creation of the subitems is performed by the BlissController itself and the plugin just ensures
        that the controller is always created before subitems and only once.

        Example: config.get(bctrl_name) or config.get(item_name) with config = bliss.config.static.get_config()

        
        # --- Plugin limitations ----

        Use references to declare subitems that also have subitems (i.e subitem of type bliss controller).
        It is possible to build a bliss controller which have subitems of the type BlissController.
        But in that case, the declaration of the subitems of the different bliss controllers cannot be
        merged in the configuration of the top controller. Each bliss controller must be decalred separately
        and one can reference this other in its config with '$name'. Using a reference to bliss_controllers 
        subitems will ensure that the plugin will associate the correct controller to subitems.
        

        # --- From config dict ---

        A BlissController can be instantiated directly (i.e. not via plugin) providing a config as a dictionary. 
        In that case, users must call the method 'self._controller_init()' just after the controller instantiation
        to ensure that the controller is initialized in the same way as the plugin does.
        The config dictionary should be structured like a YML file (i.e: nested dict and list) and
        references replaced by their corresponding object instances.
        
        Example: bctrl = BlissController( config_dict ) => bctrl._controller_init()

        
        # --- yml config example ---

        - plugin: bliss_controller    <== use the dedicated bliss controller plugin
          module: custom_module       <== module of the custom bliss controller
          class: BCMockup             <== class of the custom bliss controller
          name: bcmock                <== name of the custom bliss controller  (optional)

          com:                        <== communication config for associated hardware (optional)
            tcp:
            url: bcmock

          custom_param_1: value       <== a parameter for the custom bliss controller creation (optional)
          custom_param_2: $ref1       <== a referenced object for the controller (optional/authorized)

          sub-section-1:              <== a sub-section where subitems can be declared (optional) (ex: 'counters')
            - name: sub_item_1        <== name of the subitem (and its config)
              tag : item_tag_1        <== a tag for this item (known and interpreted by the custom bliss controller) (optional)
              sub_param_1: value      <== a custom parameter for the item creation (optional)
              device: $ref2           <== an external reference for this subitem (optional/authorized)

          sub-section-2:              <== another sub-section where subitems can be declared (optional) (ex: 'axes')
            - name: sub_item_2        <== name of the subitem (and its config)
              tag : item_tag_2        <== a tag for this item (known and interpreted by the custom bliss controller) (optional)
              input: $sub_item_1      <== an internal reference to another subitem owned by the same controller (optional/authorized)

              sub-section-2-1:        <== nested sub-sections are possible (optional)
                - name: sub_item_21
                  tag : item_tag_21

          sub-section-3 :             <== a third sub-section
            - name: $ref3             <== a subitem as an external reference is possible (optional/authorized)
              something: value
    """
Perceval Guillou's avatar
Perceval Guillou committed
156

157
    def __init__(self, config):
158
159
160
161
162
163
        self.__initialized = False
        self._subitems_config = {}  # stores items info (cfg, pkey) (filled by self._prepare_subitems_configs)
        self._subitems = {}  # stores items instances   (filled by self.__build_subitem_from_config)
        self._hw_controller = (
            None
        )  # acces the low level hardware controller interface (if any)
Perceval Guillou's avatar
Perceval Guillou committed
164

165
166
        if isinstance(config, dict):
            self._prepare_subitems_configs(config)
Perceval Guillou's avatar
Perceval Guillou committed
167

168
169
170
171
172
173
174
        # generate generic name if no controller name found in config
        self._name = config.get("name")
        if self._name is None:
            if isinstance(config, ConfigNode):
                self._name = f"{self.__class__.__name__}_{config.md5hash()}"
            else:
                self._name = f"{self.__class__.__name__}_{id(self)}"
Perceval Guillou's avatar
Perceval Guillou committed
175

176
177
178
        # config is a ConfigNode if this controller is imported from Config (i.e config.get(name))
        # or config is a dict if direct instantiation of this controller (i.e bctrl = BlissController(cfg_dict))
        self._config = config
Perceval Guillou's avatar
Perceval Guillou committed
179

180
    # ========== STANDARD METHODS ============================
181

Perceval Guillou's avatar
Perceval Guillou committed
182
    @autocomplete_property
183
    def hardware(self):
Perceval Guillou's avatar
Perceval Guillou committed
184
185
186
187
188
189
190
191
192
193
194
195
        if self._hw_controller is None:
            self._hw_controller = self._get_hardware()
        return self._hw_controller

    @property
    def name(self):
        return self._name

    @property
    def config(self):
        return self._config

196
    # ========== INTERNAL METHODS (PRIVATE) ============================
197

198
199
200
201
202
203
    def __build_subitem_from_config(self, name):
        """ 
            Standard method to create an item from its config.
            This method is called by either:
             - the plugin, via a config.get(item_name) => create_object_from_cache => name is exported in session
             - the controller, via self._get_subitem(item_name) => name is NOT exported in session
204
205
        """

206
        print(f"=== Build item {name} from {self.name}")
207

208
209
        if name not in self._subitems_config:
            raise ValueError(f"Cannot find item with name: {name}")
210

211
212
        cfg, pkey = self._subitems_config[name]
        cfg_name = cfg.get("name")
213

214
215
216
217
        if isinstance(cfg_name, str):
            item_class = self.__find_item_class(cfg, pkey)
        else:  # its a referenced object (cfg_name contains the object instance)
            item_class = None
218

219
220
221
222
223
224
225
226
        item = self._get_config_subitem(cfg_name, cfg, pkey, item_class)
        if item is None:
            msg = f"\nUnable to obtain item {cfg_name} from {self.name} with:\n"
            msg += f"  class: {item_class}\n"
            msg += f"  parent_key: '{pkey}'\n"
            msg += f"  config: {cfg}\n"
            msg += f"Check item config is supported by this controller"
            raise RuntimeError(msg)
227

228
        self._subitems[name] = item
Perceval Guillou's avatar
Perceval Guillou committed
229

230
231
232
    def __find_item_class(self, cfg, pkey):
        """
            Return a suitable class for an item of a bliss controller. 
Perceval Guillou's avatar
Perceval Guillou committed
233

234
235
236
237
238
239
240
            It tries to find a class_name in the item's config or ask the controller for a default.
            The class_name could be an absolute path, else the class is searched in the controller 
            module first. If not found, ask the controller the path of the module where the class should be found.
            
            args:
                - cfg: item config node
                - pkey: item parent key
Perceval Guillou's avatar
Perceval Guillou committed
241

242
        """
Perceval Guillou's avatar
Perceval Guillou committed
243

244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
        class_name = cfg.get("class")
        if class_name is None:  # ask default class name to the controller
            class_name = self._get_subitem_default_class_name(cfg, pkey)
            if class_name is None:
                msg = f"\nUnable to obtain default_class_name from {self.name} with:\n"
                msg += f"  parent_key: '{pkey}'\n"
                msg += f"  config: {cfg}\n"
                msg += f"Check item config is supported by this controller\n"
                raise RuntimeError(msg)

        if "." in class_name:  # from absolute path
            idx = class_name.rfind(".")
            module_name, cname = class_name[:idx], class_name[idx + 1 :]
            module = __import__(module_name, fromlist=[""])
            return getattr(module, cname)
        else:
            module = import_module(
                self.__module__
            )  # try at the controller module level first
            if hasattr(module, class_name):
                return getattr(module, class_name)
            else:  # ask the controller the module where the class should be found
                module_name = self._get_subitem_default_module(class_name, cfg, pkey)
                if module_name is None:
                    msg = f"\nUnable to obtain default_module from {self.name} with:\n"
                    msg += f"  class_name: {class_name}\n"
                    msg += f"  parent_key: '{pkey}'\n"
                    msg += f"  config: {cfg}\n"
                    msg += f"Check item config is supported by this controller\n"
                    raise RuntimeError(msg)
                module = import_module(module_name)
                if hasattr(module, class_name):
                    return getattr(module, class_name)
                else:
                    raise ModuleNotFoundError(
                        f"cannot find class {class_name} in {module}"
                    )

    def _prepare_subitems_configs(self, ctrl_node):
        """ Find all sub objects with a name in the controller config.
            Store the items config info (cfg, pkey) in the controller (including referenced items).
            Return the list of found items (excluding referenced items).
        """
Perceval Guillou's avatar
Perceval Guillou committed
287

288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
        items_list = []
        sub_cfgs = find_sub_names_config(ctrl_node)
        for level in sorted(sub_cfgs.keys()):
            if level != 0:  # ignore the controller itself
                for cfg, pkey in sub_cfgs[level]:
                    if isinstance(cfg, ConfigNode):
                        name = cfg.raw_get("name")
                    else:
                        name = cfg.get("name")

                    if isinstance(name, str):
                        # only store in items_list the subitems with a name as a string
                        # because items_list is used by the plugin to cache subitem's controller.
                        # (i.e exclude referenced names as they are not owned by this controller)
                        items_list.append(name)
                    elif isinstance(name, ConfigReference):
                        name = name.object_name
                    else:
                        name = name.name

                    self._subitems_config[name] = (cfg, pkey)

        return items_list

    def _get_subitem(self, name):
        """ return an item (create it if not alive) """
        if name not in self._subitems:
            self.__build_subitem_from_config(name)
        return self._subitems[name]

    def _controller_init(self):
        """ Instantiate a controller the same way as the plugin does.
            This method must be called if the controller has been directly 
            instantiated with a config dictionary (i.e without going through the plugin and YML config). 
        """
        if not self.__initialized:
            self._load_config()
            self._init()
Perceval Guillou's avatar
Perceval Guillou committed
326

327
    # ========== ABSTRACT METHODS ====================
Perceval Guillou's avatar
Perceval Guillou committed
328

329
330
331
    def _get_hardware(self):
        """ return the low level hardware controller interface """
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
332

333
334
335
336
    def _get_subitem_default_class_name(self, cfg, parent_key):
        # Called when the class key cannot be found in the item_config.
        # Then a default class must be returned. The choice of the item_class is usually made from the parent_key value.
        # Elements of the item_config may also by used to make the choice of the item_class.
Perceval Guillou's avatar
Perceval Guillou committed
337

338
339
340
341
342
343
344
        """ 
            Return the appropriate default class for a given item.
            args: 
                - cfg: item config node
                - parent_key: the key under which item config was found
        """
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
345

346
347
348
349
350
351
352
353
354
355
356
357
    def _get_subitem_default_module(self, class_name, cfg, parent_key):
        # Called when the given class_name (found in cfg) cannot be found at the controller module level.
        # Then a default module path must be returned. The choice of the item module is usually made from the parent_key value.
        # Elements of the item_config may also by used to make the choice of the item module.

        """ 
            Return the appropriate default class for a given item.
            args: 
                - class_name: item class name
                - cfg: item config node
                - parent_key: the key under which item config was found
        """
Perceval Guillou's avatar
Perceval Guillou committed
358

359
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
360

361
362
363
364
    def _get_config_subitem(self, name, cfg, parent_key, item_class):
        # Called when a new subitem is created (i.e accessed for the first time via self._get_subitem)
        """ 
            Return the instance of a new item owned by this controller.
365
366

            args:
367
368
369
370
                name: item name  (or instance of a referenced object if item_class is None)
                cfg : item config
                parent_key: the config key under which the item was found (ex: 'counters').
                item_class: a class to instantiate the item (=None for referenced item)
371

372
            return: item instance
373
374
375
                
        """

376
377
        # === Example ===
        # return item_class(cfg)
378

379
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
380
381

    def _load_config(self):
382
        # Called by bliss_controller plugin (after self._subitems_config has_been filled).
Perceval Guillou's avatar
Perceval Guillou committed
383

384
385
386
387
        """
            Read and apply the YML configuration of the controller. 
        """
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
388

389
390
    def _init(self):
        # Called by bliss_controller plugin (just after self._load_config)
Perceval Guillou's avatar
Perceval Guillou committed
391

392
393
394
        """
            Place holder for any action to perform after the configuration has been loaded.
        """
395
        pass
Perceval Guillou's avatar
Perceval Guillou committed
396

397
398
399
    @autocomplete_property
    def counters(self):
        raise NotImplementedError
Perceval Guillou's avatar
Perceval Guillou committed
400

401
402
403
    @autocomplete_property
    def axes(self):
        raise NotImplementedError