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.
Files changed (44) 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 +38 -0
  5. apsbits/core/catalog_init.py +29 -0
  6. apsbits/core/run_engine_init.py +71 -0
  7. apsbits/demo_instrument/README.md +1 -0
  8. apsbits/demo_instrument/__init__.py +3 -0
  9. apsbits/demo_instrument/callbacks/__init__.py +1 -0
  10. apsbits/demo_instrument/callbacks/nexus_data_file_writer.py +60 -0
  11. apsbits/demo_instrument/callbacks/spec_data_file_writer.py +93 -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 +81 -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 +63 -0
  22. apsbits/tests/__init__.py +1 -0
  23. apsbits/tests/conftest.py +32 -0
  24. apsbits/tests/test_device_factories.py +44 -0
  25. apsbits/tests/test_general.py +83 -0
  26. apsbits/tests/test_stored_dict.py +139 -0
  27. apsbits/utils/__init__.py +1 -0
  28. apsbits/utils/aps_functions.py +67 -0
  29. apsbits/utils/config_loaders.py +61 -0
  30. apsbits/utils/context_aware.py +187 -0
  31. apsbits/utils/controls_setup.py +113 -0
  32. apsbits/utils/create_new_instrument.py +144 -0
  33. apsbits/utils/helper_functions.py +113 -0
  34. apsbits/utils/logging_setup.py +211 -0
  35. apsbits/utils/make_devices_yaml.py +127 -0
  36. apsbits/utils/metadata.py +101 -0
  37. apsbits/utils/sim_creator.py +199 -0
  38. apsbits/utils/stored_dict.py +192 -0
  39. apsbits-1.0.0rc2.dist-info/LICENSE +48 -0
  40. apsbits-1.0.0rc2.dist-info/METADATA +349 -0
  41. apsbits-1.0.0rc2.dist-info/RECORD +44 -0
  42. apsbits-1.0.0rc2.dist-info/WHEEL +5 -0
  43. apsbits-1.0.0rc2.dist-info/entry_points.txt +2 -0
  44. 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")