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.
- apsbits/__init__.py +18 -0
- apsbits/_version.py +21 -0
- apsbits/core/__init__.py +11 -0
- apsbits/core/best_effort_init.py +51 -0
- apsbits/core/catalog_init.py +36 -0
- apsbits/core/run_engine_init.py +118 -0
- apsbits/demo_instrument/README.md +1 -0
- apsbits/demo_instrument/__init__.py +22 -0
- apsbits/demo_instrument/callbacks/__init__.py +1 -0
- apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +58 -0
- apsbits/demo_instrument/callbacks/spec_data_file_writer.py +97 -0
- apsbits/demo_instrument/configs/__init__.py +1 -0
- apsbits/demo_instrument/configs/devices.yml +52 -0
- apsbits/demo_instrument/configs/devices_aps_only.yml +6 -0
- apsbits/demo_instrument/configs/iconfig.yml +82 -0
- apsbits/demo_instrument/configs/logging.yml +41 -0
- apsbits/demo_instrument/devices/__init__.py +1 -0
- apsbits/demo_instrument/plans/__init__.py +8 -0
- apsbits/demo_instrument/plans/dm_plans.py +111 -0
- apsbits/demo_instrument/plans/sim_plans.py +69 -0
- apsbits/demo_instrument/startup.py +76 -0
- apsbits/demo_qserver/qs-config.yml +51 -0
- apsbits/demo_qserver/qs_host.sh +231 -0
- apsbits/demo_qserver/user_group_permissions.yaml +46 -0
- apsbits/tests/__init__.py +1 -0
- apsbits/tests/conftest.py +39 -0
- apsbits/tests/test_config.py +113 -0
- apsbits/tests/test_device_factories.py +44 -0
- apsbits/tests/test_general.py +98 -0
- apsbits/tests/test_stored_dict.py +139 -0
- apsbits/utils/__init__.py +1 -0
- apsbits/utils/aps_functions.py +67 -0
- apsbits/utils/config_loaders.py +169 -0
- apsbits/utils/controls_setup.py +107 -0
- apsbits/utils/create_new_instrument.py +129 -0
- apsbits/utils/helper_functions.py +123 -0
- apsbits/utils/logging_setup.py +211 -0
- apsbits/utils/make_devices.py +162 -0
- apsbits/utils/metadata.py +96 -0
- apsbits/utils/sim_creator.py +202 -0
- apsbits/utils/stored_dict.py +174 -0
- apsbits-1.0.0.dist-info/METADATA +195 -0
- apsbits-1.0.0.dist-info/RECORD +47 -0
- apsbits-1.0.0.dist-info/WHEEL +5 -0
- apsbits-1.0.0.dist-info/entry_points.txt +2 -0
- apsbits-1.0.0.dist-info/licenses/LICENSE +48 -0
- 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
|
+
# )
|