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,113 @@
1
+ """
2
+ Test the configuration management module.
3
+ """
4
+
5
+ import pathlib
6
+ import tempfile
7
+ from typing import TYPE_CHECKING
8
+
9
+ import pytest
10
+ import tomli_w
11
+ import yaml
12
+
13
+ from apsbits.utils.config_loaders import load_config
14
+
15
+ if TYPE_CHECKING:
16
+ pass
17
+
18
+
19
+ @pytest.fixture
20
+ def yml_config_file():
21
+ """Create a temporary YAML configuration file."""
22
+ config = {
23
+ "ICONFIG_VERSION": "2.0.0",
24
+ "DATABROKER_CATALOG": "temp",
25
+ "test_key": "test_value",
26
+ }
27
+
28
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
29
+ yaml.dump(config, f)
30
+ path = pathlib.Path(f.name)
31
+
32
+ yield path
33
+ path.unlink()
34
+
35
+
36
+ @pytest.fixture
37
+ def toml_config_file():
38
+ """Create a temporary TOML configuration file."""
39
+ config = {
40
+ "ICONFIG_VERSION": "2.0.0",
41
+ "DATABROKER_CATALOG": "temp",
42
+ "test_key": "test_value",
43
+ }
44
+
45
+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f:
46
+ f.write(tomli_w.dumps(config).encode("utf-8"))
47
+ path = pathlib.Path(f.name)
48
+
49
+ yield path
50
+ path.unlink()
51
+
52
+
53
+ def test_load_yaml_config(yml_config_file: pathlib.Path) -> None:
54
+ """
55
+ Test loading configuration from a YAML file.
56
+
57
+ Args:
58
+ yml_config_file: Path to the temporary YAML configuration file.
59
+ """
60
+ config = load_config(yml_config_file)
61
+ assert config["ICONFIG_VERSION"] == "2.0.0"
62
+ assert config["DATABROKER_CATALOG"] == "temp"
63
+ assert config["test_key"] == "test_value"
64
+
65
+
66
+ def test_load_toml_config(toml_config_file: pathlib.Path) -> None:
67
+ """
68
+ Test loading configuration from a TOML file.
69
+
70
+ Args:
71
+ toml_config_file: Path to the temporary TOML configuration file.
72
+ """
73
+ config = load_config(toml_config_file)
74
+ assert config["ICONFIG_VERSION"] == "2.0.0"
75
+ assert config["DATABROKER_CATALOG"] == "temp"
76
+ assert config["test_key"] == "test_value"
77
+
78
+
79
+ def test_load_config_none_path() -> None:
80
+ """Test loading configuration with None path."""
81
+ with pytest.raises(ValueError, match="config_path must be provided"):
82
+ load_config(None)
83
+
84
+
85
+ def test_load_config_invalid_file() -> None:
86
+ """Test loading configuration from a non-existent file."""
87
+ with pytest.raises(FileNotFoundError):
88
+ load_config(pathlib.Path("nonexistent.yml"))
89
+
90
+
91
+ def test_load_config_invalid_extension() -> None:
92
+ """Test loading configuration with an unsupported file extension."""
93
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
94
+ path = pathlib.Path(f.name)
95
+
96
+ try:
97
+ with pytest.raises(ValueError, match="Unsupported configuration file format"):
98
+ load_config(path)
99
+ finally:
100
+ path.unlink()
101
+
102
+
103
+ def test_load_config_invalid_content() -> None:
104
+ """Test loading configuration with invalid content."""
105
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
106
+ f.write("invalid: yaml: content:")
107
+ path = pathlib.Path(f.name)
108
+
109
+ try:
110
+ with pytest.raises(Exception): # noqa
111
+ load_config(path)
112
+ finally:
113
+ path.unlink()
@@ -0,0 +1,44 @@
1
+ """Test the device factories."""
2
+
3
+ import pytest
4
+
5
+ from apsbits.utils.sim_creator import motors
6
+ from apsbits.utils.sim_creator import predefined_device
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "creator, name, klass",
11
+ [
12
+ ["ophyd.sim.motor", None, "SynAxis"],
13
+ ["ophyd.sim.motor", "sim_motor", "SynAxis"],
14
+ ["ophyd.sim.noisy_det", None, "SynGauss"],
15
+ ["ophyd.sim.noisy_det", "sim_det", "SynGauss"],
16
+ ],
17
+ )
18
+ def test_predefined(creator, name, klass):
19
+ """import predefined devices"""
20
+ for device in predefined_device(creator=creator, name=name):
21
+ assert device is not None
22
+ assert device.__class__.__name__ == klass
23
+ if name is not None:
24
+ assert device.name == name
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ "kwargs",
29
+ [
30
+ {"prefix": "ioc:m", "first": 1, "last": 4, "labels": ["motor"]},
31
+ {"prefix": "ioc:m", "names": "m", "first": 7, "last": 22, "labels": ["motor"]},
32
+ ],
33
+ )
34
+ def test_motors(kwargs):
35
+ """create a block of motors"""
36
+ count = 0
37
+ for device in motors(**kwargs):
38
+ count += 1
39
+ assert device is not None
40
+ assert device.__class__.__name__ == "EpicsMotor"
41
+ if kwargs.get("names") is None:
42
+ assert device.name.startswith("m")
43
+ assert isinstance(int(device.name[1:]), int)
44
+ assert count == (1 + kwargs["last"] - kwargs["first"])
@@ -0,0 +1,98 @@
1
+ """
2
+ Test that instrument can be started.
3
+
4
+ Here is just enough testing to get a CI workflow started. More are possible.
5
+ """
6
+
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from apsbits.demo_instrument.plans.sim_plans import sim_count_plan
12
+ from apsbits.demo_instrument.plans.sim_plans import sim_print_plan
13
+ from apsbits.demo_instrument.plans.sim_plans import sim_rel_scan_plan
14
+ from apsbits.demo_instrument.startup import bec
15
+ from apsbits.demo_instrument.startup import cat
16
+ from apsbits.demo_instrument.startup import peaks
17
+ from apsbits.demo_instrument.startup import running_in_queueserver
18
+ from apsbits.demo_instrument.startup import sd
19
+ from apsbits.demo_instrument.startup import specwriter
20
+ from apsbits.utils.config_loaders import get_config
21
+
22
+
23
+ def test_startup(runengine_with_devices: object) -> None:
24
+ """
25
+ Test that standard startup works and the RunEngine has initialized the devices.
26
+
27
+ Parameters
28
+ ----------
29
+ runengine_with_devices : object
30
+ Fixture providing initialized RunEngine with devices.
31
+ """
32
+ assert runengine_with_devices is not None
33
+ assert cat is not None
34
+ assert bec is not None
35
+ assert peaks is not None
36
+ assert sd is not None
37
+ assert specwriter is not None
38
+
39
+ iconfig = get_config()
40
+ if iconfig.get("DATABROKER_CATALOG", "temp") == "temp":
41
+ assert len(cat) == 0
42
+ assert not running_in_queueserver()
43
+
44
+
45
+ @pytest.mark.parametrize(
46
+ "plan, n_uids",
47
+ [
48
+ [sim_print_plan, 0],
49
+ [sim_count_plan, 1],
50
+ [sim_rel_scan_plan, 1],
51
+ ],
52
+ )
53
+ def test_sim_plans(runengine_with_devices: object, plan: object, n_uids: int) -> None:
54
+ """
55
+ Test supplied simulator plans using the RunEngine with devices.
56
+ """
57
+ bec.disable_plots()
58
+ # Get the initial number of runs in the catalog
59
+ n_runs = len(cat)
60
+ # Use the fixture-provided run engine to run the plan.
61
+ uids = runengine_with_devices(plan())
62
+ assert len(uids) == n_uids
63
+ # Add a small delay to ensure data is saved
64
+ time.sleep(0.1)
65
+ # For sim_print_plan, we don't expect any new runs
66
+ if plan == sim_print_plan:
67
+ assert len(cat) == n_runs
68
+ else:
69
+ assert len(cat) == n_runs + len(uids)
70
+
71
+
72
+ def test_iconfig() -> None:
73
+ """
74
+ Test the instrument configuration.
75
+ """
76
+ iconfig = get_config()
77
+
78
+ version: str = iconfig.get(
79
+ "ICONFIG_VERSION", "0.0.0"
80
+ ) # TODO: Will anyone ever have a wrong catalog version?
81
+ assert version >= "2.0.0"
82
+
83
+ cat_name: str = iconfig.get("DATABROKER_CATALOG")
84
+ assert cat_name is not None
85
+ assert cat_name == cat.name
86
+
87
+ assert "RUN_ENGINE" in iconfig
88
+ assert "DEFAULT_METADATA" in iconfig["RUN_ENGINE"]
89
+
90
+ default_md = iconfig["RUN_ENGINE"]["DEFAULT_METADATA"]
91
+ assert "beamline_id" in default_md
92
+ assert "instrument_name" in default_md
93
+ assert "proposal_id" in default_md
94
+ assert "databroker_catalog" in default_md
95
+ assert default_md["databroker_catalog"] == cat.name
96
+
97
+ xmode = iconfig.get("XMODE_DEBUG_LEVEL")
98
+ assert xmode is not None
@@ -0,0 +1,139 @@
1
+ """
2
+ Test the utils.stored_dict module.
3
+ """
4
+
5
+ import pathlib
6
+ import tempfile
7
+ import time
8
+ from contextlib import nullcontext as does_not_raise
9
+
10
+ import pytest
11
+
12
+ from apsbits.utils.config_loaders import load_config_yaml
13
+ from apsbits.utils.stored_dict import StoredDict
14
+
15
+
16
+ def luftpause(delay=0.05):
17
+ """A brief wait for content to flush to storage."""
18
+ time.sleep(max(0, delay))
19
+
20
+
21
+ @pytest.fixture
22
+ def md_file():
23
+ """Provide a temporary file (deleted on close)."""
24
+ tfile = tempfile.NamedTemporaryFile(
25
+ prefix="re_md_",
26
+ suffix=".yml",
27
+ delete=False,
28
+ )
29
+ path = pathlib.Path(tfile.name)
30
+ yield pathlib.Path(tfile.name)
31
+
32
+ if path.exists():
33
+ path.unlink() # delete the file
34
+
35
+
36
+ def test_StoredDict(md_file):
37
+ """Test the StoredDict class."""
38
+ assert md_file.exists()
39
+ assert len(open(md_file).read().splitlines()) == 0 # empty
40
+
41
+ sdict = StoredDict(md_file, delay=0.2, title="unit testing")
42
+ assert sdict is not None
43
+ assert len(sdict) == 0
44
+ assert sdict._delay == 0.2
45
+ assert sdict._title == "unit testing"
46
+ assert len(open(md_file).read().splitlines()) == 0 # still empty
47
+ assert sdict._sync_key == f"sync_agent_{id(sdict):x}"
48
+ assert not sdict.sync_in_progress
49
+
50
+ # Write an empty dictionary.
51
+ sdict.flush()
52
+ luftpause()
53
+ buf = open(md_file).read().splitlines()
54
+ assert len(buf) == 4, f"{buf=}"
55
+ assert buf[-1] == "{}" # The empty dict.
56
+ assert buf[0].startswith("# ")
57
+ assert buf[1].startswith("# ")
58
+ assert "unit testing" in buf[0]
59
+
60
+ # Add a new {key: value} pair.
61
+ assert not sdict.sync_in_progress
62
+ sdict["a"] = 1
63
+ assert sdict.sync_in_progress
64
+ sdict.flush()
65
+ assert time.time() > sdict._sync_deadline
66
+ luftpause()
67
+ assert not sdict.sync_in_progress
68
+ assert len(open(md_file).read().splitlines()) == 4
69
+
70
+ # Change the only value.
71
+ sdict["a"] = 2
72
+ sdict.flush()
73
+ luftpause()
74
+ assert len(open(md_file).read().splitlines()) == 4 # Still.
75
+
76
+ # Add another key.
77
+ sdict["bee"] = "bumble"
78
+ sdict.flush()
79
+ print(f"\n\nthis is the md_file: {md_file}\n\n")
80
+ luftpause()
81
+ assert len(open(md_file).read().splitlines()) == 5
82
+
83
+ # Test _delayed_sync_to_storage.
84
+ sdict["bee"] = "queen"
85
+ md = load_config_yaml(md_file)
86
+ assert len(md) == 2 # a & bee
87
+ assert "a" in md
88
+ assert md["bee"] == "bumble" # The old value.
89
+
90
+ time.sleep(sdict._delay / 2)
91
+ # Still not written ...
92
+ assert load_config_yaml(md_file)["bee"] == "bumble"
93
+
94
+ time.sleep(sdict._delay)
95
+ # Should be written by now.
96
+ assert load_config_yaml(md_file)["bee"] == "queen"
97
+
98
+ del sdict["bee"] # __delitem__
99
+ assert "bee" not in sdict # __getitem__
100
+
101
+
102
+ @pytest.mark.parametrize(
103
+ "md, xcept, text",
104
+ [
105
+ [{"a": 1}, None, str(None)], # int value is ok
106
+ [{"a": 2.2}, None, str(None)], # float value is ok
107
+ [{"a": "3"}, None, str(None)], # str value is ok
108
+ [{"a": [4, 5, 6]}, None, str(None)], # list value is ok
109
+ [{"a": {"bb": [4, 5, 6]}}, None, str(None)], # nested value is ok
110
+ [{1: 1}, None, str(None)], # int key is ok
111
+ [{"a": object()}, TypeError, "not JSON serializable"],
112
+ [{object(): 1}, TypeError, "keys must be str, int, float, "],
113
+ [{"a": [4, object(), 6]}, TypeError, "not JSON serializable"],
114
+ [{"a": {object(): [4, 5, 6]}}, TypeError, "keys must be str, int, "],
115
+ ],
116
+ )
117
+ def test_set_exceptions(md, xcept, text, md_file):
118
+ """Cases that might raise an exception."""
119
+ sdict = StoredDict(md_file, delay=0.2, title="unit testing")
120
+ context = does_not_raise() if xcept is None else pytest.raises(xcept)
121
+ with context as reason:
122
+ sdict.update(md)
123
+ assert text in str(reason), f"{reason=}"
124
+
125
+
126
+ def test_popitem(md_file):
127
+ """Can't popitem from empty dict."""
128
+ sdict = StoredDict(md_file, delay=0.2, title="unit testing")
129
+ with pytest.raises(KeyError) as reason:
130
+ sdict.popitem()
131
+ assert "dictionary is empty" in str(reason), f"{reason=}"
132
+
133
+
134
+ def test_repr(md_file):
135
+ """__repr__"""
136
+ sdict = StoredDict(md_file, delay=0.1, title="unit testing")
137
+ sdict["a"] = 1
138
+ assert repr(sdict) == "<StoredDict {'a': 1}>"
139
+ assert str(sdict) == "<StoredDict {'a': 1}>"
@@ -0,0 +1 @@
1
+ """Local utilties and miscellaneous code."""
@@ -0,0 +1,67 @@
1
+ """
2
+ APS utility helper functions
3
+ ============================
4
+
5
+ .. autosummary::
6
+ ~host_on_aps_subnet
7
+ ~aps_dm_setup
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import pathlib
13
+ import socket
14
+
15
+ logger = logging.getLogger(__name__)
16
+ logger.bsdev(__file__)
17
+
18
+
19
+ def aps_dm_setup(dm_setup_file_path):
20
+ """
21
+ APS Data Management setup
22
+ =========================
23
+
24
+ Read the bash shell script file used by DM to setup the environment. Parse any
25
+ ``export`` lines and add their environment variables to this session. This is
26
+ done by brute force here since the APS DM environment setup requires different
27
+ Python code than bluesky and the two often clash.
28
+
29
+ This setup must be done before any of the DM package libraries are called.
30
+
31
+ """
32
+ if dm_setup_file_path is not None:
33
+ bash_script = pathlib.Path(dm_setup_file_path)
34
+ if bash_script.exists():
35
+ logger.info("APS DM environment file: %s", str(bash_script))
36
+ # parse environment variables from bash script
37
+ environment = {}
38
+ for line in open(bash_script).readlines():
39
+ if not line.startswith("export "):
40
+ continue
41
+ k, v = line.strip().split()[-1].split("=")
42
+ environment[k] = v
43
+ os.environ.update(environment)
44
+
45
+ workflow_owner = os.environ["DM_STATION_NAME"].lower()
46
+ logger.info("APS DM workflow owner: %s", workflow_owner)
47
+ else:
48
+ logger.warning("APS DM setup file does not exist: '%s'", bash_script)
49
+
50
+
51
+ def host_on_aps_subnet():
52
+ """Detect if this host is on an APS subnet."""
53
+ LOOPBACK_IP4 = "127.0.0.1"
54
+ PUBLIC_IP4_PREFIX = "164.54."
55
+ PRIVATE_IP4_PREFIX = "10.54."
56
+ TEST_IP = "10.254.254.254" # does not have to be reachable
57
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
58
+ sock.settimeout(0)
59
+ try:
60
+ sock.connect((TEST_IP, 1))
61
+ ip4 = sock.getsockname()[0]
62
+ except Exception:
63
+ ip4 = LOOPBACK_IP4
64
+ return True in [
65
+ ip4.startswith(PUBLIC_IP4_PREFIX),
66
+ ip4.startswith(PRIVATE_IP4_PREFIX),
67
+ ]
@@ -0,0 +1,169 @@
1
+ """
2
+ Configuration management for the instrument.
3
+
4
+ This module serves as the single source of truth for instrument configuration.
5
+ It loads and validates the configuration from the iconfig.yml file and provides
6
+ access to the configuration throughout the application.
7
+ """
8
+
9
+ import logging
10
+ import pathlib
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from typing import Dict
14
+ from typing import Optional
15
+
16
+ import tomli # type: ignore
17
+ import yaml
18
+
19
+ logger = logging.getLogger(__name__)
20
+ logger.bsdev(__file__)
21
+
22
+ # Global configuration instance
23
+ _iconfig: Dict[str, Any] = {}
24
+
25
+
26
+ def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
27
+ """
28
+ Load configuration from a YAML or TOML file.
29
+
30
+ Args:
31
+ config_path: Path to the configuration file.
32
+
33
+ Returns:
34
+ The loaded configuration dictionary.
35
+
36
+ Raises:
37
+ ValueError: If config_path is None or if the file extension is not supported.
38
+ FileNotFoundError: If the configuration file does not exist.
39
+ """
40
+ global _iconfig
41
+
42
+ if config_path is None:
43
+ raise ValueError("config_path must be provided")
44
+
45
+ if not config_path.exists():
46
+ raise FileNotFoundError(f"Configuration file not found at {config_path}")
47
+
48
+ try:
49
+ with open(config_path, "rb") as f:
50
+ if config_path.suffix.lower() == ".yml":
51
+ config = yaml.safe_load(f)
52
+ elif config_path.suffix.lower() == ".toml":
53
+ config = tomli.load(f)
54
+ else:
55
+ raise ValueError(
56
+ f"Unsupported configuration file format: {config_path.suffix}"
57
+ )
58
+
59
+ if config is None:
60
+ config = {}
61
+ _iconfig.update(config)
62
+
63
+ _iconfig["ICONFIG_PATH"] = str(config_path)
64
+ _iconfig["INSTRUMENT_PATH"] = str(config_path.parent)
65
+ _iconfig["INSTRUMENT_FOLDER"] = str(config_path.parent.name)
66
+
67
+ return _iconfig
68
+ except Exception as e:
69
+ logger.error(f"Error loading configuration: {e}")
70
+ raise
71
+
72
+
73
+ def get_config() -> Dict[str, Any]:
74
+ """
75
+ Get the current configuration.
76
+
77
+ Returns:
78
+ The current configuration dictionary.
79
+ """
80
+ return _iconfig
81
+
82
+
83
+ def update_config(updates: Dict[str, Any]) -> None:
84
+ """
85
+ Update the current configuration.
86
+
87
+ Args:
88
+ updates: Dictionary of configuration updates.
89
+ """
90
+ _iconfig.update(updates)
91
+
92
+
93
+ # def load_config_yaml(config_path: Optional[Path] = None) -> Dict[str, Any]:
94
+ # """
95
+ # Load configuration from a YAML file.
96
+
97
+ # Args:
98
+ # config_path: Path to the configuration file.
99
+
100
+ # Returns:
101
+ # The loaded configuration dictionary.
102
+
103
+ # Raises:
104
+ # FileNotFoundError: If the configuration file does not exist.
105
+ # """
106
+ # if config_path is None:
107
+ # raise ValueError("config_path must be provided")
108
+
109
+ # if not config_path.exists():
110
+ # raise FileNotFoundError(f"Configuration file not found at {config_path}")
111
+
112
+ # try:
113
+ # with open(config_path) as f:
114
+ # config = yaml.safe_load(f)
115
+ # if config is None:
116
+ # config = {}
117
+ # return config
118
+ # except Exception as e:
119
+ # logger.error(f"Error loading configuration: {e}")
120
+ # raise
121
+
122
+
123
+ def load_config_yaml(config_obj) -> dict:
124
+ """
125
+ Load configuration from a YAML file.
126
+
127
+ Args:
128
+ config_path: Path to the configuration file.
129
+
130
+ Returns:
131
+ The loaded configuration dictionary.
132
+
133
+ Raises:
134
+ FileNotFoundError: If the configuration file does not exist.
135
+ """
136
+
137
+ if config_obj is None:
138
+ raise ValueError("config_path must be provided")
139
+
140
+ try:
141
+ # If it's a path, open it first
142
+ if isinstance(config_obj, (str, pathlib.Path)):
143
+ with open(config_obj, "r") as f:
144
+ content = f.read()
145
+ # Otherwise assume it's a file-like object
146
+ else:
147
+ content = config_obj.read()
148
+
149
+ iconfig = yaml.load(content, yaml.Loader)
150
+ return iconfig
151
+ except Exception as e:
152
+ logger.error(f"Error loading configuration: {e}")
153
+ raise
154
+
155
+
156
+ # class IConfigFileVersionError(ValueError):
157
+ # """Configuration file version too old."""
158
+
159
+
160
+ # # Validate the iconfig file has the minimum version.
161
+ # _version = iconfig.get("ICONFIG_VERSION")
162
+ # print(f"\n\n\niconfig version: {_version}\n\n\n")
163
+ # if _version is None or _version < ICONFIG_MINIMUM_VERSION:
164
+ # raise IConfigFileVersionError(
165
+ # "Configuration file version too old."
166
+ # f" Found {_version!r}."
167
+ # f" Expected minimum {ICONFIG_MINIMUM_VERSION!r}."
168
+ # f" Configuration file '{DEFAULT_ICONFIG_YML_FILE}'."
169
+ # )