apsbits 1.0.0rc2__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 +38 -0
- apsbits/core/catalog_init.py +29 -0
- apsbits/core/run_engine_init.py +71 -0
- apsbits/demo_instrument/README.md +1 -0
- apsbits/demo_instrument/__init__.py +3 -0
- apsbits/demo_instrument/callbacks/__init__.py +1 -0
- apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +60 -0
- apsbits/demo_instrument/callbacks/spec_data_file_writer.py +93 -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 +81 -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 +63 -0
- apsbits/tests/__init__.py +1 -0
- apsbits/tests/conftest.py +32 -0
- apsbits/tests/test_device_factories.py +44 -0
- apsbits/tests/test_general.py +83 -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 +61 -0
- apsbits/utils/context_aware.py +187 -0
- apsbits/utils/controls_setup.py +113 -0
- apsbits/utils/create_new_instrument.py +144 -0
- apsbits/utils/helper_functions.py +113 -0
- apsbits/utils/logging_setup.py +211 -0
- apsbits/utils/make_devices_yaml.py +127 -0
- apsbits/utils/metadata.py +101 -0
- apsbits/utils/sim_creator.py +199 -0
- apsbits/utils/stored_dict.py +192 -0
- apsbits-1.0.0rc2.dist-info/LICENSE +48 -0
- apsbits-1.0.0rc2.dist-info/METADATA +349 -0
- apsbits-1.0.0rc2.dist-info/RECORD +44 -0
- apsbits-1.0.0rc2.dist-info/WHEEL +5 -0
- apsbits-1.0.0rc2.dist-info/entry_points.txt +2 -0
- apsbits-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimalist configuration system following the 'configs convention'.
|
|
3
|
+
|
|
4
|
+
Core principle: Configuration always lives in the 'configs' subdirectory
|
|
5
|
+
of wherever startup.py is located.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import pathlib
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Dict
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(Exception):
|
|
19
|
+
"""Base exception for configuration errors."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StartupConfig:
|
|
25
|
+
"""Configuration provider following the configs-subdirectory convention."""
|
|
26
|
+
|
|
27
|
+
CONFIG_FILENAMES = [
|
|
28
|
+
"iconfig.yml",
|
|
29
|
+
"config.yml",
|
|
30
|
+
"iconfig.yaml",
|
|
31
|
+
"config.yaml",
|
|
32
|
+
"config.toml",
|
|
33
|
+
"ifconfig.toml",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
"""Initialize the configuration."""
|
|
38
|
+
self._config = None
|
|
39
|
+
|
|
40
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
41
|
+
"""Get a configuration value with an optional default."""
|
|
42
|
+
return self.config.get(key, default)
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, key: str) -> Any:
|
|
45
|
+
"""Dictionary-style access to configuration."""
|
|
46
|
+
try:
|
|
47
|
+
return self.config[key]
|
|
48
|
+
except KeyError:
|
|
49
|
+
raise KeyError(f"Missing required config key: '{key}'") from None
|
|
50
|
+
|
|
51
|
+
def __contains__(self, key: str) -> bool:
|
|
52
|
+
"""Support for 'in' operator."""
|
|
53
|
+
return key in self.config
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
"""Create a helpful string representation."""
|
|
57
|
+
startup_name = self.startup_path.parent.name if self.startup_path else "unknown"
|
|
58
|
+
config_file = self.config_file.name if self.config_file else "none"
|
|
59
|
+
return f"<Config: {startup_name}/configs/{config_file}>"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def config(self) -> Dict:
|
|
63
|
+
"""Get the configuration dictionary, loading it if necessary."""
|
|
64
|
+
if self._config is None:
|
|
65
|
+
self._config = self._load_config()
|
|
66
|
+
return self._config
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict:
|
|
69
|
+
"""Convert config to a regular dictionary for serialization."""
|
|
70
|
+
return dict(self.config)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
@lru_cache(maxsize=1) # noqa B019
|
|
74
|
+
def startup_path(self) -> Optional[pathlib.Path]:
|
|
75
|
+
"""Find the startup.py that's being executed."""
|
|
76
|
+
# First check main module
|
|
77
|
+
startup = self._find_startup_in_main()
|
|
78
|
+
if startup:
|
|
79
|
+
return startup
|
|
80
|
+
|
|
81
|
+
# Then check call stack
|
|
82
|
+
return self._find_startup_in_stack()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def configs_dir(self) -> Optional[pathlib.Path]:
|
|
86
|
+
"""Get the configs directory path."""
|
|
87
|
+
if not self.startup_path:
|
|
88
|
+
return None
|
|
89
|
+
return self.startup_path.parent / "configs"
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
@lru_cache(maxsize=1) # noqa B019
|
|
93
|
+
def config_file(self) -> Optional[pathlib.Path]:
|
|
94
|
+
"""Find the active config file."""
|
|
95
|
+
if not self.configs_dir or not self.configs_dir.exists():
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Try each filename in order
|
|
99
|
+
for filename in self.CONFIG_FILENAMES:
|
|
100
|
+
path = self.configs_dir / filename
|
|
101
|
+
if path.exists():
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def resolve_path(self, filename: str) -> Optional[pathlib.Path]:
|
|
107
|
+
"""Resolve a filename relative to the configs directory."""
|
|
108
|
+
if not self.configs_dir:
|
|
109
|
+
return None
|
|
110
|
+
return self.configs_dir / filename
|
|
111
|
+
|
|
112
|
+
def _find_startup_in_main(self) -> Optional[pathlib.Path]:
|
|
113
|
+
"""Check if the main module is startup.py."""
|
|
114
|
+
import sys
|
|
115
|
+
|
|
116
|
+
main_module = sys.modules.get("__main__")
|
|
117
|
+
if main_module and hasattr(main_module, "__file__"):
|
|
118
|
+
path = pathlib.Path(main_module.__file__).resolve()
|
|
119
|
+
if path.name == "startup.py":
|
|
120
|
+
return path
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _find_startup_in_stack(self) -> Optional[pathlib.Path]:
|
|
124
|
+
"""Search the call stack for a startup.py file."""
|
|
125
|
+
for frame_info in inspect.stack():
|
|
126
|
+
module = inspect.getmodule(frame_info.frame)
|
|
127
|
+
if not module or not hasattr(module, "__file__"):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
path = pathlib.Path(module.__file__).resolve()
|
|
131
|
+
|
|
132
|
+
# Direct match if this is startup.py
|
|
133
|
+
if path.name == "startup.py":
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
# Check if there's a startup.py in the same directory
|
|
137
|
+
startup_path = path.parent / "startup.py"
|
|
138
|
+
if startup_path.exists():
|
|
139
|
+
return startup_path
|
|
140
|
+
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def _load_config(self) -> Dict:
|
|
144
|
+
"""Load the configuration file."""
|
|
145
|
+
if not self.config_file:
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with open(self.config_file) as f:
|
|
150
|
+
return yaml.safe_load(f) or {}
|
|
151
|
+
except Exception:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Create a single instance
|
|
156
|
+
_config = StartupConfig()
|
|
157
|
+
|
|
158
|
+
# Public API - Simple and clean
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get(key: str, default: Any = None) -> Any:
|
|
162
|
+
"""Get a configuration value with an optional default."""
|
|
163
|
+
return _config.get(key, default)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_dict() -> Dict:
|
|
167
|
+
"""Get the entire configuration as a dictionary (for serialization)."""
|
|
168
|
+
return _config.to_dict()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_path(filename: str) -> pathlib.Path:
|
|
172
|
+
"""Resolve a filename relative to the configs directory."""
|
|
173
|
+
path = _config.resolve_path(filename)
|
|
174
|
+
if not path:
|
|
175
|
+
raise ConfigError(f"Cannot resolve '{filename}': No startup.py found")
|
|
176
|
+
return path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_configs_dir() -> pathlib.Path:
|
|
180
|
+
"""Get the path to the configs directory."""
|
|
181
|
+
if not _config.configs_dir:
|
|
182
|
+
raise ConfigError("No configs directory found (No startup.py detected)")
|
|
183
|
+
return _config.configs_dir
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# For backward compatibility
|
|
187
|
+
iconfig = _config
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPICS & ophyd related setup
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~oregistry
|
|
7
|
+
~set_control_layer
|
|
8
|
+
~set_timeouts
|
|
9
|
+
~epics_scan_id_source
|
|
10
|
+
~connect_scan_id_pv
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
import ophyd
|
|
16
|
+
from ophyd.signal import EpicsSignalBase
|
|
17
|
+
from ophydregistry import Registry
|
|
18
|
+
|
|
19
|
+
from apsbits.utils.config_loaders import iconfig
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
logger.bsdev(__file__)
|
|
23
|
+
|
|
24
|
+
re_config = iconfig.get("RUN_ENGINE", {})
|
|
25
|
+
|
|
26
|
+
DEFAULT_CONTROL_LAYER = "PyEpics"
|
|
27
|
+
DEFAULT_TIMEOUT = 60 # default used next...
|
|
28
|
+
ophyd_config = iconfig.get("OPHYD", {})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def epics_scan_id_source(_md):
|
|
32
|
+
"""
|
|
33
|
+
Callback function for RunEngine. Returns *next* scan_id to be used.
|
|
34
|
+
|
|
35
|
+
* Ignore metadata dictionary passed as argument.
|
|
36
|
+
* Get current scan_id from PV.
|
|
37
|
+
* Apply lower limit of zero.
|
|
38
|
+
* Increment (so that scan_id numbering starts from 1).
|
|
39
|
+
* Set PV with new value.
|
|
40
|
+
* Return new value.
|
|
41
|
+
|
|
42
|
+
Exception will be raised if PV is not connected when next
|
|
43
|
+
``bps.open_run()`` is called.
|
|
44
|
+
"""
|
|
45
|
+
scan_id_epics = oregistry.find(name="scan_id_epics")
|
|
46
|
+
new_scan_id = max(scan_id_epics.get(), 0) + 1
|
|
47
|
+
scan_id_epics.put(new_scan_id)
|
|
48
|
+
return new_scan_id
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def connect_scan_id_pv(RE, pv: str = None):
|
|
52
|
+
"""
|
|
53
|
+
Define a PV to use for the RunEngine's `scan_id`.
|
|
54
|
+
"""
|
|
55
|
+
from ophyd import EpicsSignal
|
|
56
|
+
|
|
57
|
+
pv = pv or re_config.get("SCAN_ID_PV")
|
|
58
|
+
if pv is None:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
scan_id_epics = EpicsSignal(pv, name="scan_id_epics")
|
|
63
|
+
except TypeError: # when Sphinx substitutes EpicsSignal with _MockModule
|
|
64
|
+
return
|
|
65
|
+
logger.info("Using EPICS PV %r for RunEngine 'scan_id'", pv)
|
|
66
|
+
|
|
67
|
+
# Setup the RunEngine to call epics_scan_id_source()
|
|
68
|
+
# which uses the EPICS PV to provide the scan_id.
|
|
69
|
+
RE.scan_id_source = epics_scan_id_source
|
|
70
|
+
|
|
71
|
+
scan_id_epics.wait_for_connection()
|
|
72
|
+
try:
|
|
73
|
+
RE.md["scan_id_pv"] = scan_id_epics.pvname
|
|
74
|
+
RE.md["scan_id"] = scan_id_epics.get() # set scan_id from EPICS
|
|
75
|
+
except TypeError:
|
|
76
|
+
pass # Ignore PersistentDict errors that only raise when making the docs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_control_layer(control_layer: str = DEFAULT_CONTROL_LAYER):
|
|
80
|
+
"""
|
|
81
|
+
Communications library between ophyd and EPICS Channel Access.
|
|
82
|
+
|
|
83
|
+
Choices are: PyEpics (default) or caproto.
|
|
84
|
+
|
|
85
|
+
OPHYD_CONTROL_LAYER is an application of "lessons learned."
|
|
86
|
+
|
|
87
|
+
Only used in a couple rare cases where PyEpics code was failing.
|
|
88
|
+
It's defined here since it was difficult to find how to do this
|
|
89
|
+
in the ophyd documentation.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
control_layer = ophyd_config.get("CONTROL_LAYER", control_layer)
|
|
93
|
+
ophyd.set_cl(control_layer.lower())
|
|
94
|
+
|
|
95
|
+
logger.info("using ophyd control layer: %r", ophyd.cl.name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_timeouts():
|
|
99
|
+
"""Set default timeout for all EpicsSignal connections & communications."""
|
|
100
|
+
if not EpicsSignalBase._EpicsSignalBase__any_instantiated:
|
|
101
|
+
# Only BEFORE any EpicsSignalBase (or subclass) are created!
|
|
102
|
+
timeouts = ophyd_config.get("TIMEOUTS", {})
|
|
103
|
+
EpicsSignalBase.set_defaults(
|
|
104
|
+
auto_monitor=True,
|
|
105
|
+
timeout=timeouts.get("PV_READ", DEFAULT_TIMEOUT),
|
|
106
|
+
write_timeout=timeouts.get("PV_WRITE", DEFAULT_TIMEOUT),
|
|
107
|
+
connection_timeout=iconfig.get("PV_CONNECTION", DEFAULT_TIMEOUT),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
oregistry = Registry(auto_register=True)
|
|
112
|
+
"""Registry of all ophyd-style Devices and Signals."""
|
|
113
|
+
oregistry.warn_duplicates = False
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Create a new instrument from a fixed template.
|
|
4
|
+
|
|
5
|
+
Copies the template directory and updates pyproject.toml and .templatesyncignore.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import toml
|
|
19
|
+
except ImportError:
|
|
20
|
+
print("Missing 'toml' package. Install with: pip install toml")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def copy_instrument(template_dir: Path, destination_dir: Path) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Copy template directory to the destination.
|
|
27
|
+
"""
|
|
28
|
+
shutil.copytree(str(template_dir), str(destination_dir))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_pyproject(
|
|
32
|
+
pyproject_path: Path, instrument_name: str, instrument_path: Path
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Update pyproject.toml with the new instrument.
|
|
36
|
+
|
|
37
|
+
Adds the instrument to [tool.instruments] and also to [tool.setuptools.package-dir].
|
|
38
|
+
"""
|
|
39
|
+
with pyproject_path.open("r", encoding="utf-8") as file:
|
|
40
|
+
config: dict[str, Any] = toml.load(file)
|
|
41
|
+
|
|
42
|
+
config.setdefault("tool", {})
|
|
43
|
+
# Update instruments section
|
|
44
|
+
config["tool"].setdefault("instruments", {})
|
|
45
|
+
|
|
46
|
+
relative_path: str = str(
|
|
47
|
+
instrument_path.resolve().relative_to(pyproject_path.parent.resolve())
|
|
48
|
+
)
|
|
49
|
+
config["tool"]["instruments"][instrument_name] = {"path": relative_path}
|
|
50
|
+
|
|
51
|
+
# Update package-dir section
|
|
52
|
+
setuptools_config: dict[str, Any] = config["tool"].setdefault("setuptools", {})
|
|
53
|
+
pkg_dir: dict[str, str] = setuptools_config.setdefault("package-dir", {})
|
|
54
|
+
pkg_dir[instrument_name] = relative_path
|
|
55
|
+
|
|
56
|
+
with pyproject_path.open("w", encoding="utf-8") as file:
|
|
57
|
+
toml.dump(config, file)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def update_templatesyncignore(relative_path: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Append the instrument path to the .templatesyncignore file.
|
|
63
|
+
"""
|
|
64
|
+
tsync_file: Path = Path(".templatesyncignore").resolve()
|
|
65
|
+
lines: list[str] = []
|
|
66
|
+
if tsync_file.exists():
|
|
67
|
+
lines = tsync_file.read_text(encoding="utf-8").splitlines()
|
|
68
|
+
if relative_path in lines:
|
|
69
|
+
return
|
|
70
|
+
# Append a newline if needed.
|
|
71
|
+
with tsync_file.open("a", encoding="utf-8") as f:
|
|
72
|
+
if lines and lines[-1].strip() != "":
|
|
73
|
+
f.write("\n")
|
|
74
|
+
f.write(relative_path + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None:
|
|
78
|
+
"""
|
|
79
|
+
Parse args and create the instrument.
|
|
80
|
+
"""
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
description="Create an instrument from a fixed template."
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"name", type=str, help="New instrument name; must be a valid package name."
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument("dest", type=str, help="Destination directory.")
|
|
88
|
+
args = parser.parse_args()
|
|
89
|
+
|
|
90
|
+
if re.fullmatch(r"[a-z][_a-z0-9]*", args.name) is None:
|
|
91
|
+
print(f"Error: Invalid instrument name '{args.name}'.", file=sys.stderr)
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
template_path: Path = Path("src/bits/demo_instrument").resolve()
|
|
95
|
+
destination_parent: Path = Path(args.dest).resolve()
|
|
96
|
+
new_instrument_dir: Path = destination_parent / args.name
|
|
97
|
+
|
|
98
|
+
print(
|
|
99
|
+
f"Creating instrument '{args.name}' from '{template_path}' into \
|
|
100
|
+
'{new_instrument_dir}'."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not template_path.exists():
|
|
104
|
+
print(f"Error: Template '{template_path}' does not exist.", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
if new_instrument_dir.exists():
|
|
108
|
+
print(f"Error: Destination '{new_instrument_dir}' exists.", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
copy_instrument(template_path, new_instrument_dir)
|
|
113
|
+
print(f"Template copied to '{new_instrument_dir}'.")
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
print(f"Error copying instrument: {exc}", file=sys.stderr)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
pyproject_path: Path = Path("pyproject.toml").resolve()
|
|
119
|
+
if not pyproject_path.exists():
|
|
120
|
+
print("Error: pyproject.toml not found.", file=sys.stderr)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
update_pyproject(pyproject_path, args.name, new_instrument_dir)
|
|
125
|
+
print(f"pyproject.toml updated with '{args.name}'.")
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
print(f"Error updating pyproject.toml: {exc}", file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
new_relative_path: str = str(
|
|
132
|
+
new_instrument_dir.resolve().relative_to(pyproject_path.parent.resolve())
|
|
133
|
+
)
|
|
134
|
+
update_templatesyncignore(new_relative_path)
|
|
135
|
+
print(f".templatesyncignore updated with '{new_relative_path}'.")
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
print(f"Error updating .templatesyncignore: {exc}", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
print(f"Instrument '{args.name}' created.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic utility helper functions
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
.. autosummary::
|
|
6
|
+
~register_bluesky_magics
|
|
7
|
+
~running_in_queueserver
|
|
8
|
+
~debug_python
|
|
9
|
+
~mpl_setup
|
|
10
|
+
~is_notebook
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
import matplotlib as mpl
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
from bluesky.magics import BlueskyMagics
|
|
18
|
+
from bluesky_queueserver import is_re_worker_active
|
|
19
|
+
from IPython import get_ipython
|
|
20
|
+
|
|
21
|
+
from apsbits.utils.config_loaders import iconfig
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
logger.bsdev(__file__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_bluesky_magics() -> None:
|
|
28
|
+
"""
|
|
29
|
+
Register Bluesky magics if an IPython environment is detected.
|
|
30
|
+
|
|
31
|
+
This function registers the BlueskyMagics if get_ipython() returns a valid IPython
|
|
32
|
+
instance.
|
|
33
|
+
"""
|
|
34
|
+
ipython = get_ipython()
|
|
35
|
+
if ipython is not None:
|
|
36
|
+
ipython.register_magics(BlueskyMagics)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def running_in_queueserver() -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Detect if running in the bluesky queueserver.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
bool: True if running in the queueserver, False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
active: bool = is_re_worker_active()
|
|
48
|
+
return active
|
|
49
|
+
except Exception as cause:
|
|
50
|
+
print(f"{cause=}")
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def debug_python() -> None:
|
|
55
|
+
"""
|
|
56
|
+
Enable detailed debugging for Python exceptions in the IPython environment.
|
|
57
|
+
|
|
58
|
+
This function adjusts the xmode settings for exception tracebacks based on the
|
|
59
|
+
configuration.
|
|
60
|
+
"""
|
|
61
|
+
ipython = get_ipython()
|
|
62
|
+
if ipython is not None:
|
|
63
|
+
xmode_level: str = iconfig.get("XMODE_DEBUG_LEVEL", "Minimal")
|
|
64
|
+
ipython.run_line_magic("xmode", xmode_level)
|
|
65
|
+
print("\nEnd of IPython settings\n")
|
|
66
|
+
logger.bsdev("xmode exception level: '%s'", xmode_level)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_notebook() -> bool:
|
|
70
|
+
"""
|
|
71
|
+
Detect if the current environment is a Jupyter Notebook.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
bool: True if running in a notebook (Jupyter notebook or qtconsole),
|
|
75
|
+
False otherwise.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
shell: str = get_ipython().__class__.__name__
|
|
79
|
+
if shell == "ZMQInteractiveShell":
|
|
80
|
+
return True # Jupyter notebook or qtconsole
|
|
81
|
+
elif shell == "TerminalInteractiveShell":
|
|
82
|
+
return False # Terminal running IPython
|
|
83
|
+
else:
|
|
84
|
+
return False # Other type
|
|
85
|
+
except NameError:
|
|
86
|
+
return False # Standard Python interpreter
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mpl_setup() -> None:
|
|
90
|
+
"""
|
|
91
|
+
Configure the Matplotlib backend based on the current environment.
|
|
92
|
+
|
|
93
|
+
For non-queueserver and non-notebook environments, attempts to use the 'qtAgg'
|
|
94
|
+
backend.
|
|
95
|
+
If 'qtAgg' is not available due to missing dependencies, falls back to the 'Agg'
|
|
96
|
+
backend.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
None
|
|
100
|
+
"""
|
|
101
|
+
if not running_in_queueserver():
|
|
102
|
+
if not is_notebook():
|
|
103
|
+
try:
|
|
104
|
+
mpl.use("qtAgg")
|
|
105
|
+
plt.ion()
|
|
106
|
+
logger.bsdev("Using qtAgg backend for matplotlib.")
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
logger.error(
|
|
109
|
+
"qtAgg backend is not available, falling back to Agg backend. \
|
|
110
|
+
Error: %s",
|
|
111
|
+
exc,
|
|
112
|
+
)
|
|
113
|
+
mpl.use("Agg")
|