apsbits 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. apsbits/__init__.py +18 -0
  2. apsbits/_version.py +21 -0
  3. apsbits/core/__init__.py +11 -0
  4. apsbits/core/best_effort_init.py +51 -0
  5. apsbits/core/catalog_init.py +36 -0
  6. apsbits/core/run_engine_init.py +118 -0
  7. apsbits/demo_instrument/README.md +1 -0
  8. apsbits/demo_instrument/__init__.py +22 -0
  9. apsbits/demo_instrument/callbacks/__init__.py +1 -0
  10. apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +58 -0
  11. apsbits/demo_instrument/callbacks/spec_data_file_writer.py +97 -0
  12. apsbits/demo_instrument/configs/__init__.py +1 -0
  13. apsbits/demo_instrument/configs/devices.yml +52 -0
  14. apsbits/demo_instrument/configs/devices_aps_only.yml +6 -0
  15. apsbits/demo_instrument/configs/iconfig.yml +82 -0
  16. apsbits/demo_instrument/configs/logging.yml +41 -0
  17. apsbits/demo_instrument/devices/__init__.py +1 -0
  18. apsbits/demo_instrument/plans/__init__.py +8 -0
  19. apsbits/demo_instrument/plans/dm_plans.py +111 -0
  20. apsbits/demo_instrument/plans/sim_plans.py +69 -0
  21. apsbits/demo_instrument/startup.py +76 -0
  22. apsbits/demo_qserver/qs-config.yml +51 -0
  23. apsbits/demo_qserver/qs_host.sh +231 -0
  24. apsbits/demo_qserver/user_group_permissions.yaml +46 -0
  25. apsbits/tests/__init__.py +1 -0
  26. apsbits/tests/conftest.py +39 -0
  27. apsbits/tests/test_config.py +113 -0
  28. apsbits/tests/test_device_factories.py +44 -0
  29. apsbits/tests/test_general.py +98 -0
  30. apsbits/tests/test_stored_dict.py +139 -0
  31. apsbits/utils/__init__.py +1 -0
  32. apsbits/utils/aps_functions.py +67 -0
  33. apsbits/utils/config_loaders.py +169 -0
  34. apsbits/utils/controls_setup.py +107 -0
  35. apsbits/utils/create_new_instrument.py +129 -0
  36. apsbits/utils/helper_functions.py +123 -0
  37. apsbits/utils/logging_setup.py +211 -0
  38. apsbits/utils/make_devices.py +162 -0
  39. apsbits/utils/metadata.py +96 -0
  40. apsbits/utils/sim_creator.py +202 -0
  41. apsbits/utils/stored_dict.py +174 -0
  42. apsbits-1.0.0.dist-info/METADATA +195 -0
  43. apsbits-1.0.0.dist-info/RECORD +47 -0
  44. apsbits-1.0.0.dist-info/WHEEL +5 -0
  45. apsbits-1.0.0.dist-info/entry_points.txt +2 -0
  46. apsbits-1.0.0.dist-info/licenses/LICENSE +48 -0
  47. apsbits-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,162 @@
1
+ """
2
+ Make devices from YAML files
3
+ =============================
4
+
5
+ Construct ophyd-style devices from simple specifications in YAML files.
6
+
7
+ .. autosummary::
8
+ :nosignatures:
9
+
10
+ ~make_devices
11
+ ~Instrument
12
+ """
13
+
14
+ import logging
15
+ import pathlib
16
+ import sys
17
+ import time
18
+
19
+ import guarneri
20
+ from apstools.plans import run_blocking_function
21
+ from apstools.utils import dynamic_import
22
+ from bluesky import plan_stubs as bps
23
+
24
+ from apsbits.utils.aps_functions import host_on_aps_subnet
25
+ from apsbits.utils.config_loaders import get_config
26
+ from apsbits.utils.config_loaders import load_config_yaml
27
+ from apsbits.utils.controls_setup import oregistry # noqa: F401
28
+
29
+ logger = logging.getLogger(__name__)
30
+ logger.bsdev(__file__)
31
+
32
+
33
+ def make_devices(*, pause: float = 1):
34
+ """
35
+ (plan stub) Create the ophyd-style controls for this instrument.
36
+
37
+ Feel free to modify this plan to suit the needs of your instrument.
38
+
39
+ EXAMPLE::
40
+
41
+ RE(make_devices())
42
+
43
+ PARAMETERS
44
+
45
+ pause : float
46
+ Wait 'pause' seconds (default: 1) for slow objects to connect.
47
+
48
+ """
49
+
50
+ logger.debug("(Re)Loading local control objects.")
51
+
52
+ iconfig = get_config()
53
+
54
+ instrument_path = pathlib.Path(iconfig.get("INSTRUMENT_PATH")).parent
55
+ configs_path = instrument_path / "configs"
56
+
57
+ # Get device files and ensure it's a list
58
+ device_files = iconfig.get("DEVICES_FILES", [])
59
+ if isinstance(device_files, str):
60
+ device_files = [device_files]
61
+ logger.debug("Loading device files: %r", device_files)
62
+
63
+ # Load each device file
64
+ for device_file in device_files:
65
+ device_path = configs_path / device_file
66
+ if not device_path.exists():
67
+ logger.error(f"Device file not found: {device_path}")
68
+ continue
69
+ logger.info(f"Loading device file: {device_path}")
70
+ try:
71
+ yield from run_blocking_function(_loader, device_path, main=True)
72
+ except Exception as e:
73
+ logger.error(f"Error loading device file {device_path}: {str(e)}")
74
+ continue
75
+
76
+ # Handle APS-specific device files if on APS subnet
77
+ aps_control_devices_files = iconfig.get("APS_DEVICES_FILES", [])
78
+ if isinstance(aps_control_devices_files, str):
79
+ aps_control_devices_files = [aps_control_devices_files]
80
+
81
+ if aps_control_devices_files and host_on_aps_subnet():
82
+ for device_file in aps_control_devices_files:
83
+ device_path = configs_path / device_file
84
+ if not device_path.exists():
85
+ logger.error(f"APS device file not found: {device_path}")
86
+ continue
87
+ logger.info(f"Loading APS device file: {device_path}")
88
+ try:
89
+ yield from run_blocking_function(_loader, device_path, main=True)
90
+ except Exception as e:
91
+ logger.error(f"Error loading APS device file {device_path}: {str(e)}")
92
+ continue
93
+
94
+ if pause > 0:
95
+ logger.debug(
96
+ "Waiting %s seconds for slow objects to connect.",
97
+ pause,
98
+ )
99
+ yield from bps.sleep(pause)
100
+
101
+ # Configure any of the controls here, or in plan stubs
102
+
103
+
104
+ def _loader(yaml_device_file, main=True):
105
+ """
106
+ Load our ophyd-style controls as described in a YAML file.
107
+
108
+ PARAMETERS
109
+
110
+ yaml_device_file : str or pathlib.Path
111
+ YAML file describing ophyd-style controls to be created.
112
+ main : bool
113
+ If ``True`` add these devices to the ``__main__`` namespace.
114
+
115
+ """
116
+ logger.debug("Devices file %r.", str(yaml_device_file))
117
+ t0 = time.time()
118
+ _instr.load(yaml_device_file)
119
+ logger.info("Devices loaded in %.3f s.", time.time() - t0)
120
+
121
+ if main:
122
+ main_namespace = sys.modules["__main__"]
123
+ for label in oregistry.device_names:
124
+ logger.info(f"Setting up {label} in main namespace")
125
+ setattr(main_namespace, label, oregistry[label])
126
+
127
+
128
+ class Instrument(guarneri.Instrument):
129
+ """Custom YAML loader for guarneri."""
130
+
131
+ def parse_yaml_file(self, config_file: pathlib.Path | str) -> list[dict]:
132
+ """Read device configurations from YAML format file."""
133
+ if isinstance(config_file, str):
134
+ config_file = pathlib.Path(config_file)
135
+
136
+ def parser(creator, specs):
137
+ if creator not in self.device_classes:
138
+ self.device_classes[creator] = dynamic_import(creator)
139
+ entries = [
140
+ {
141
+ "device_class": creator,
142
+ "args": (), # ALL specs are kwargs!
143
+ "kwargs": table,
144
+ }
145
+ for table in specs
146
+ ]
147
+ return entries
148
+
149
+ with open(config_file, "r") as f:
150
+ config_data = load_config_yaml(f)
151
+
152
+ devices = [
153
+ device
154
+ # parse the file using already loaded config data
155
+ for k, v in config_data.items()
156
+ # each support type (class, factory, function, ...)
157
+ for device in parser(k, v)
158
+ ]
159
+ return devices
160
+
161
+
162
+ _instr = Instrument({}, registry=oregistry) # singleton
@@ -0,0 +1,96 @@
1
+ """
2
+ RunEngine Metadata
3
+ ==================
4
+
5
+ .. autosummary::
6
+ ~MD_PATH
7
+ ~get_md_path
8
+ ~re_metadata
9
+ """
10
+
11
+ import getpass
12
+ import logging
13
+ import os
14
+ import pathlib
15
+ import socket
16
+ import sys
17
+
18
+ import apstools
19
+ import bluesky
20
+ import databroker
21
+ import epics
22
+ import h5py
23
+ import intake
24
+ import matplotlib
25
+ import numpy
26
+ import ophyd
27
+ import pyRestTable
28
+ import pysumreg
29
+ import spec2nexus
30
+
31
+ import apsbits
32
+
33
+ logger = logging.getLogger(__name__)
34
+ logger.bsdev(__file__)
35
+
36
+
37
+ DEFAULT_MD_PATH = pathlib.Path.home() / ".config" / "Bluesky_RunEngine_md"
38
+ HOSTNAME = socket.gethostname() or "localhost"
39
+ USERNAME = getpass.getuser() or "Bluesky user"
40
+ VERSIONS = dict(
41
+ apstools=apstools.__version__,
42
+ bluesky=bluesky.__version__,
43
+ databroker=databroker.__version__,
44
+ epics=epics.__version__,
45
+ h5py=h5py.__version__,
46
+ intake=intake.__version__,
47
+ matplotlib=matplotlib.__version__,
48
+ numpy=numpy.__version__,
49
+ ophyd=ophyd.__version__,
50
+ pyRestTable=pyRestTable.__version__,
51
+ python=sys.version.split(" ")[0],
52
+ pysumreg=pysumreg.__version__,
53
+ spec2nexus=spec2nexus.__version__,
54
+ apsbits=apsbits.__version__,
55
+ )
56
+
57
+
58
+ def get_md_path(iconfig=None):
59
+ """
60
+ Get path for RE metadata.
61
+
62
+ ============== ==============================================
63
+ support path
64
+ ============== ==============================================
65
+ PersistentDict Directory where dictionary keys are stored in separate files.
66
+ StoredDict File where dictionary is stored as YAML.
67
+ ============== ==============================================
68
+
69
+ In either case, the 'path' can be relative or absolute. Relative
70
+ paths are with respect to the present working directory when the
71
+ bluesky session is started.
72
+ """
73
+ RE_CONFIG = iconfig.get("RUN_ENGINE", {})
74
+ md_path_name = RE_CONFIG.get("MD_PATH", DEFAULT_MD_PATH)
75
+ path = pathlib.Path(md_path_name)
76
+ logger.info("RunEngine metadata saved in directory: %s", str(path))
77
+ return str(path)
78
+
79
+
80
+ def re_metadata(iconfig=None, cat=None):
81
+ """Programmatic metadata for the RunEngine."""
82
+ md = {
83
+ "login_id": f"{USERNAME}@{HOSTNAME}",
84
+ "versions": VERSIONS,
85
+ "pid": os.getpid(),
86
+ "iconfig": iconfig,
87
+ }
88
+ if cat is not None:
89
+ md["databroker_catalog"] = cat.name
90
+ RE_CONFIG = iconfig.get("RUN_ENGINE", {})
91
+ md.update(RE_CONFIG.get("DEFAULT_METADATA", {}))
92
+
93
+ conda_prefix = os.environ.get("CONDA_PREFIX")
94
+ if conda_prefix is not None:
95
+ md["conda_prefix"] = conda_prefix
96
+ return md
@@ -0,0 +1,202 @@
1
+ """
2
+ Device factories
3
+ ================
4
+
5
+ Device factories are used to:
6
+
7
+ * *Create* several similar ophyd-style devices (such as ``ophyd.Device``
8
+ or ``ophyd.Signal``) that fit a pattern.
9
+
10
+ * *Import* a device which is pre-defined in a module, such as the
11
+ ophyd simulators in ``ophyd.sim``.
12
+
13
+ .. autosummary::
14
+
15
+ ~factory_base
16
+ ~motors
17
+ ~predefined_device
18
+ """
19
+
20
+ import logging
21
+
22
+ from apstools.utils import dynamic_import
23
+
24
+ from apsbits.utils.controls_setup import oregistry
25
+
26
+ logger = logging.getLogger(__name__)
27
+ logger.bsdev(__file__)
28
+
29
+
30
+ def predefined_device(*, name="", creator=""):
31
+ """
32
+ Provide a predefined device such as from the 'ophyd.sim' module.
33
+
34
+ PARAMETERS
35
+
36
+ creator : str
37
+ Name of the predefined device to be used
38
+ name : str
39
+ Simulator will be assigned this name. (default: use existing name)
40
+
41
+ Example entry in `devices.yml` file:
42
+
43
+ .. code-block:: yaml
44
+ :linenos:
45
+
46
+ instrument.devices.factories.predefined_device:
47
+ - {creator: ophyd.sim.motor, name: sim_motor}
48
+ - {creator: ophyd.sim.noisy_det, name: sim_det}
49
+ """
50
+ if creator == "":
51
+ raise ValueError("Must provide a value for 'creator'.")
52
+ device = dynamic_import(creator)
53
+ if name != "":
54
+ device.name = name
55
+ logger.debug(device)
56
+ oregistry.register(device)
57
+ yield device
58
+
59
+
60
+ def factory_base(
61
+ *,
62
+ prefix=None,
63
+ names="object{}",
64
+ first=0,
65
+ last=0,
66
+ creator="ophyd.Signal",
67
+ **kwargs,
68
+ ):
69
+ """
70
+ Make one or more objects using 'creator'.
71
+
72
+ PARAMETERS
73
+
74
+ prefix : str
75
+ Prefix *pattern* for the EPICS PVs (default: ``None``).
76
+
77
+ names : str
78
+ Name *pattern* for the objects. The default pattern is ``"object{}"`` which
79
+ produces devices named ``object1, object2, ..., ```. If a formatting
80
+ specification (``{}``) is not provided, it is appended. Each object
81
+ will be named using this code: ``names.format(number)``, such as::
82
+
83
+ In [23]: "object{}".format(22)
84
+ Out[23]: 'object22'
85
+
86
+ first : int
87
+ The first object number in the continuous series from 'first' through
88
+ 'last' (inclusive).
89
+
90
+ last : int
91
+ The first object number in the continuous series from 'first' through
92
+ 'last' (inclusive).
93
+
94
+ creator : str
95
+ Name of the *creator* code that will be used to construct each device.
96
+ (default: ``"ophyd.Signal"``)
97
+
98
+ kwargs : dict
99
+ Dictionary of additional keyword arguments. This is included
100
+ when creating each object.
101
+ """
102
+ if "{" not in names:
103
+ names += "{}"
104
+ if prefix is not None and "{" not in prefix:
105
+ prefix += "{}"
106
+
107
+ klass = dynamic_import(creator)
108
+
109
+ first, last = sorted([first, last])
110
+ for i in range(first, 1 + last):
111
+ keywords = {"name": names.format(i)}
112
+ if prefix is not None:
113
+ keywords["prefix"] = prefix.format(i)
114
+ keywords.update(kwargs)
115
+ device = klass(**keywords)
116
+ logger.debug(device)
117
+ yield device
118
+
119
+
120
+ def motors(
121
+ *,
122
+ prefix=None,
123
+ names="m{}",
124
+ first=0,
125
+ last=0,
126
+ **kwargs,
127
+ ):
128
+ """
129
+ Make one or more '``ophyd.EpicsMotor``' objects.
130
+
131
+ Example entry in `devices.yml` file:
132
+
133
+ .. code-block:: yaml
134
+ :linenos:
135
+
136
+ instrument.devices.factories.motors:
137
+ - {prefix: "ioc:m", first: 1, last: 4, labels: ["motor"]}
138
+ # skip m5 & m6
139
+ - {prefix: "ioc:m", first: 7, last: 22, labels: ["motor"]}
140
+
141
+ Uses this pattern:
142
+
143
+ .. code-block:: py
144
+ :linenos:
145
+
146
+ ophyd.EpicsMotor(
147
+ prefix=prefix.format(i),
148
+ name=names.format(i),
149
+ **kwargs,
150
+ )
151
+
152
+ where ``i`` iterates from 'first' through 'last' (inclusive).
153
+
154
+ PARAMETERS
155
+
156
+ prefix : str
157
+ Name *pattern* for the EPICS PVs. There is no default pattern. If a
158
+ formatting specification (``{}``) is not provided, it is appended (as
159
+ with other ophyd devices). Each motor will be configured with this
160
+ prefix: ``prefix.format(number)``, such as::
161
+
162
+ In [23]: "ioc:m{}".format(22)
163
+ Out[23]: 'ioc:m22'
164
+
165
+ names : str
166
+ Name *pattern* for the motors. The default pattern is ``"m{}"`` which
167
+ produces motors named ``m1, m2, ..., m22, m23, ...```. If a formatting
168
+ specification (``{}``) is not provided, it is appended. Each motor
169
+ will be named using this code: ``names.format(number)``, such as::
170
+
171
+ In [23]: "m{}".format(22)
172
+ Out[23]: 'm22'
173
+
174
+ first : int
175
+ The first motor number in the continuous series from 'first' through
176
+ 'last' (inclusive).
177
+
178
+ last : int
179
+ The first motor number in the continuous series from 'first' through
180
+ 'last' (inclusive).
181
+
182
+ kwargs : dict
183
+ Dictionary of additional keyword arguments. This is included
184
+ with each EpicsMotor object.
185
+ """
186
+ if prefix is None:
187
+ raise ValueError("Must define a string value for 'prefix'.")
188
+
189
+ kwargs["names"] = names or "m{}"
190
+ kwargs["prefix"] = prefix
191
+ kwargs.update(
192
+ {
193
+ "prefix": prefix,
194
+ "names": names or "m{}",
195
+ "first": first,
196
+ "last": last,
197
+ "creator": "ophyd.EpicsMotor",
198
+ }
199
+ )
200
+
201
+ for motor in factory_base(**kwargs):
202
+ yield motor
@@ -0,0 +1,174 @@
1
+ """
2
+ Storage-backed Dictionary
3
+ =========================
4
+ A dictionary that writes its contents to YAML file.
5
+ Replaces ``bluesky.utils.PersistentDict``.
6
+ * Contents must be JSON serializable.
7
+ * Contents stored in a single human-readable YAML file.
8
+ * Sync to disk shortly after dictionary is updated.
9
+ .. autosummary::
10
+ ~StoredDict
11
+ """
12
+
13
+ __all__ = ["StoredDict"]
14
+
15
+ import collections.abc
16
+ import datetime
17
+ import inspect
18
+ import json
19
+ import logging
20
+ import pathlib
21
+ import threading
22
+ import time
23
+
24
+ import yaml
25
+
26
+ logger = logging.getLogger(__name__)
27
+ logger.bsdev(__file__)
28
+
29
+
30
+ class StoredDict(collections.abc.MutableMapping):
31
+ """
32
+ Dictionary that syncs to storage.
33
+ .. autosummary::
34
+ ~flush
35
+ ~popitem
36
+ ~reload
37
+ .. rubric:: Static methods
38
+ All support for the YAML format is implemented in the static methods.
39
+ .. autosummary::
40
+ ~dump
41
+ ~load
42
+ ----
43
+ """
44
+
45
+ def __init__(self, file, delay=5, title=None, serializable=True):
46
+ """
47
+ StoredDict : Dictionary that syncs to storage
48
+ PARAMETERS
49
+ file : str or pathlib.Path
50
+ Name of file to store dictionary contents.
51
+ delay : number
52
+ Time delay (s) since last dictionary update to write to storage.
53
+ Default: 5 seconds.
54
+ title : str or None
55
+ Comment to write at top of file.
56
+ Default: "Written by StoredDict."
57
+ serializable : bool
58
+ If True, validate new dictionary entries are JSON serializable.
59
+ """
60
+ self._file = pathlib.Path(file)
61
+ self._delay = max(0, delay)
62
+ self._title = title or f"Written by {self.__class__.__name__}."
63
+ self.test_serializable = serializable
64
+ self.sync_in_progress = False
65
+ self._sync_deadline = time.time()
66
+ self._sync_key = f"sync_agent_{id(self):x}"
67
+ self._sync_loop_period = 0.005
68
+
69
+ self._cache = {}
70
+ self.reload()
71
+
72
+ def __delitem__(self, key):
73
+ """Delete dictionary value by key."""
74
+ del self._cache[key]
75
+
76
+ def __getitem__(self, key):
77
+ """Get dictionary value by key."""
78
+ return self._cache[key]
79
+
80
+ def __iter__(self):
81
+ """Iterate over the dictionary keys."""
82
+ yield from self._cache
83
+
84
+ def __len__(self):
85
+ """Number of keys in the dictionary."""
86
+ return len(self._cache)
87
+
88
+ def __repr__(self):
89
+ """representation of this object."""
90
+ return f"<{self.__class__.__name__} {dict(self)!r}>"
91
+
92
+ def __setitem__(self, key, value):
93
+ """Write to the dictionary."""
94
+ outermost_frame = inspect.getouterframes(inspect.currentframe())[-1]
95
+ if "sphinx-build" in outermost_frame.filename:
96
+ # Seems that Sphinx is building the documentation.
97
+ # Ignore all the objects it tries to add.
98
+ return
99
+
100
+ if self.test_serializable:
101
+ json.dumps({key: value})
102
+ self._cache[key] = value # Store the new (or revised) content.
103
+
104
+ # Reset the deadline.
105
+ self._sync_deadline = time.time() + self._delay
106
+ logger.debug("new sync deadline in %f s.", self._delay)
107
+ if not self.sync_in_progress:
108
+ # Start the sync_agent (thread).
109
+ self._delayed_sync_to_storage()
110
+
111
+ def _delayed_sync_to_storage(self):
112
+ """
113
+ Sync the metadata to storage.
114
+ Start a time-delay thread. New writes to the metadata dictionary will
115
+ extend the deadline. Sync once the deadline is reached.
116
+ """
117
+
118
+ def sync_agent():
119
+ """Threaded task."""
120
+ logger.debug("Starting sync_agent...")
121
+ self.sync_in_progress = True
122
+ while time.time() < self._sync_deadline:
123
+ time.sleep(self._sync_loop_period)
124
+ logger.debug("Sync waiting period ended")
125
+ self.sync_in_progress = False
126
+
127
+ StoredDict.dump(self._file, self._cache, title=self._title)
128
+
129
+ thred = threading.Thread(target=sync_agent)
130
+ thred.start()
131
+
132
+ def flush(self):
133
+ """Force a write of the dictionary to disk"""
134
+ logger.debug("flush()")
135
+ if not self.sync_in_progress:
136
+ StoredDict.dump(self._file, self._cache, title=self._title)
137
+ self._sync_deadline = time.time()
138
+ self.sync_in_progress = False
139
+
140
+ def popitem(self):
141
+ """
142
+ Remove and return a (key, value) pair as a 2-tuple.
143
+
144
+ Pairs are returned in LIFO (last-in, first-out) order.
145
+ Raises KeyError if the dict is empty.
146
+ """
147
+ return self._cache.popitem()
148
+
149
+ def reload(self):
150
+ """Read dictionary from storage."""
151
+ logger.debug("reload()")
152
+ self._cache = StoredDict.load(self._file)
153
+
154
+ @staticmethod
155
+ def dump(file, contents, title=None):
156
+ """Write dictionary to YAML file."""
157
+ logger.debug("_dump(): file='%s', contents=%r, title=%r", file, contents, title)
158
+ with open(file, "w") as f:
159
+ if isinstance(title, str) and len(title) > 0:
160
+ f.write(f"# {title}\n")
161
+ f.write(f"# Dictionary contents written: {datetime.datetime.now()}\n\n")
162
+ f.write(yaml.dump(contents, indent=2))
163
+
164
+ @staticmethod
165
+ def load(file):
166
+ """Read dictionary from YAML file."""
167
+ from .config_loaders import load_config_yaml
168
+
169
+ file = pathlib.Path(file)
170
+ logger.debug("_load('%s')", file)
171
+ md = None
172
+ if file.exists():
173
+ md = load_config_yaml(file)
174
+ return md or {} # In case file is empty.