pyaml-cs-osprey 2026.11.0__tar.gz

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.
@@ -0,0 +1,226 @@
1
+ # Scratch notes (local idea capture)
2
+ scratch/
3
+
4
+ # NFS temporary files
5
+ .nfs*
6
+
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+
12
+ # C extensions
13
+ *.so
14
+
15
+ # Distribution / packaging
16
+ .Python
17
+ /build/
18
+ develop-eggs/
19
+ /dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+
33
+ # PyInstaller
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .coverage
45
+ .coverage*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ coverage.json
50
+ *.cover
51
+ .hypothesis/
52
+
53
+ # CI diagnostics drop (per-worker event logs, stack dumps, container logs).
54
+ # Written by _harness/ci_diagnostics.py and .github/actions/capture-ci-diagnostics
55
+ # whenever OSPREY_CI_DIAG_DIR is set; uploaded as an artifact, never committed.
56
+ /ci-diag/
57
+ .pytest_cache/
58
+ .mypy_cache/
59
+ # The import graph lint-imports (import-linter) caches between runs.
60
+ .import_linter_cache/
61
+
62
+ # Jupyter Notebook
63
+ .ipynb_checkpoints
64
+
65
+ # Environment directories
66
+ .env
67
+ .venv
68
+ env/
69
+ venv/
70
+ ENV/
71
+ env.bak/
72
+ venv.bak/
73
+
74
+ # IDE specific files
75
+ .idea/
76
+ .vscode/
77
+ *.swp
78
+ *.swo
79
+
80
+ # OS specific files
81
+ .DS_Store
82
+ .DS_Store?
83
+ ._*
84
+ .Spotlight-V100
85
+ .Trashes
86
+ ehthumbs.db
87
+ Thumbs.db
88
+
89
+ # Data, models, and results
90
+ !data/.gitkeep
91
+ # models/ - Removed this to allow tracking model_config.py
92
+ !models/.gitkeep
93
+ results/
94
+ !results/.gitkeep
95
+ # Any source dir named results/ is SOURCE, not ML output — the broad unanchored
96
+ # results/ ignore above must not swallow it (this negation is deliberately
97
+ # path-independent: pinning it to one package broke when the bluesky-web
98
+ # sidecar moved services/ -> interfaces/, silently dropping the panel from the move).
99
+ !src/**/results/
100
+ # Benchmark model-matrix run exhaust (#259) — regenerable from scripts/benchmark/.
101
+ # All generated artifacts (dashboard.html, results_box/, live_updater.log) live
102
+ # under one output dir co-located with the tooling, keeping the repo root clean.
103
+ scripts/benchmark/_out/
104
+ *.h5
105
+ *.pkl
106
+ *.pt
107
+ *.pth
108
+ *.onnx
109
+ *.mlmodel
110
+
111
+ # Logs
112
+ logs/
113
+ *.log
114
+ !tests/deployment/fixtures/*.log
115
+ tensorboard/
116
+
117
+ # Temporary files
118
+ tmp/
119
+ temp/
120
+
121
+ *.jpg
122
+ *.gif
123
+ *.bmp
124
+ *.tiff
125
+ *.ico
126
+ # The demo template's CANOPY screens ship their images.
127
+ !src/osprey/templates/apps/demo/data/canopy/library/screens/**/images/*.jpg
128
+
129
+ # Landing-page demo video: recorded takes are never committed; the docs build
130
+ # downloads the MP4s from the release manifest.json names. The posters and
131
+ # manifest.json are committed, so the posters must override the `*.jpg` rule.
132
+ docs/demo-video/
133
+ docs/source/_static/demo/*.mp4
134
+ !docs/source/_static/demo/*-poster.jpg
135
+
136
+ # Credentials
137
+ .env
138
+
139
+ delete_me*
140
+ valves.json
141
+ *.code-workspace
142
+
143
+ # Agent data directories
144
+ _agent_data/*
145
+ # Track only the Codex marketplace manifest; never Codex plugin state.
146
+ .agents/*
147
+ !.agents/plugins/
148
+ .agents/plugins/*
149
+ !.agents/plugins/marketplace.json
150
+
151
+ # Local coding-agent guidance (kept out of git; see /CLAUDE.md below)
152
+ AGENTS.md
153
+
154
+ # Sample logbook data (large JSONL files)
155
+ logbook_*.jsonl
156
+
157
+ # Auto-generated documentation
158
+ _autosummary/
159
+ docs/build/
160
+
161
+ # PID files
162
+ *.pid
163
+
164
+ # Development services and applications (keep locally but not in git)
165
+ /.claude
166
+ _ISSUES
167
+ /CLAUDE.md
168
+ .mcp.json
169
+
170
+ # Git worktrees
171
+ .worktrees/
172
+
173
+ # Generated benchmark databases
174
+ data/benchmarks/cross_paradigm/tier*/
175
+
176
+ # OSPREY workspace (runtime data, not tracked — now covered by _agent_data/* above)
177
+
178
+ # Vendor assets (fetched at build time via `osprey vendor fetch`)
179
+ src/osprey/interfaces/artifacts/static/js/vendor/
180
+ src/osprey/interfaces/artifacts/static/css/vendor/
181
+ src/osprey/interfaces/web_terminal/static/vendor/*
182
+ # The dock engine is tracked: vendored from CANOPY's screen runtime at a pinned commit.
183
+ !src/osprey/interfaces/web_terminal/static/vendor/dock-engine.mjs
184
+ !src/osprey/interfaces/web_terminal/static/vendor/dock-engine.d.mts
185
+ src/osprey/interfaces/bluesky_web/panels/bluesky/vendor/
186
+ src/osprey/interfaces/optimizer_web/panels/optimizer/vendor/
187
+ # The okf panel's own vendored marked.min.js is tracked; only DOMPurify is fetched.
188
+ src/osprey/interfaces/okf_panel/static/vendor/purify.min.js
189
+ tests/e2e/claude_code/benchmark_results/
190
+
191
+ # Model files staged by hand for hosts that cannot reach the model host. They
192
+ # reach the qmd sidecar over the read-only mount `services.qmd.models_dir`
193
+ # names, never through a build context — but the staging directory itself can
194
+ # sit anywhere, this tree included. Gigabytes of GGUF, never source — keep them
195
+ # out of git and out of the build (see also the hatch exclude in pyproject.toml)
196
+ **/prefetched-models/
197
+
198
+ # Playwright MCP browser-session cache (page snapshots, console logs)
199
+ .playwright-mcp/
200
+
201
+ # Node.js (dev-only JS toolchain; not part of the Python wheel)
202
+ node_modules/
203
+
204
+ # Working directory gitlab-ci-local writes when the deploy goldens are checked
205
+ # against a real pipeline parser. Created wherever the check is run from.
206
+ .gitlab-ci-local/
207
+
208
+ # Version stamp written by the hatch-vcs build hook. Committing it would recreate
209
+ # the stale-literal problem the git-derived version exists to remove. It is still
210
+ # packaged into the wheel and sdist — the hook registers it as a build artifact.
211
+ src/osprey/_version.py
212
+ packages/osprey-connectors/src/osprey_connectors/_version.py
213
+ packages/osprey-optimizer/src/osprey_optimizer/_version.py
214
+ packages/pyaml-cs-osprey/src/pyaml_cs_osprey/_version.py
215
+
216
+ # feature-scaffolding logs (not committed)
217
+ .claude/.logs/
218
+
219
+ # Process scaffolding — never committed (specs/plans live here uncommitted)
220
+ docs/superpowers/
221
+
222
+ # Locally scaffolded deployment project. Carries a real .env, build output and
223
+ # run data; belongs to the working copy, never to the repo. Anchored to the root
224
+ # so the pattern cannot swallow a same-named path elsewhere in the tree.
225
+ /my-control-assistant/
226
+ /osprey-demo/
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyaml-cs-osprey
3
+ Version: 2026.11.0
4
+ Summary: pyAML control-system backend that routes every access through the OSPREY connector
5
+ License: BSD-3-Clause
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: accelerator-middle-layer-apg==0.3.2.post1
9
+ Requires-Dist: osprey-connectors>=2026.11.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pyaml-cs-osprey
13
+
14
+ A pyAML control-system backend for OSPREY. pyAML installs from
15
+ `accelerator-middle-layer-apg`, the staging distribution (upstream
16
+ 0.3.2 plus the first-turn trajectory tool), and imports as `pyaml`. The
17
+ backend implements pyAML's `ControlSystem`, `DeviceAccess` and
18
+ `DeviceAccessList` on top of `osprey.runtime`, so every pyAML read and write
19
+ passes through the OSPREY `ControlSystemConnector`: channel limits, write
20
+ posture, control-target pinning, write verification and audit all apply.
21
+ pyAML's native backends, which open their own control-system clients, are not
22
+ used.
23
+
24
+ The package ships with `osprey-framework`, which depends on it; it is not meant
25
+ to be installed on its own.
26
+
27
+ ## Selecting the backend
28
+
29
+ In a pyAML configuration:
30
+
31
+ ```yaml
32
+ controls:
33
+ - type: pyaml_cs_osprey.controlsystem
34
+ name: live
35
+ ```
@@ -0,0 +1,24 @@
1
+ # pyaml-cs-osprey
2
+
3
+ A pyAML control-system backend for OSPREY. pyAML installs from
4
+ `accelerator-middle-layer-apg`, the staging distribution (upstream
5
+ 0.3.2 plus the first-turn trajectory tool), and imports as `pyaml`. The
6
+ backend implements pyAML's `ControlSystem`, `DeviceAccess` and
7
+ `DeviceAccessList` on top of `osprey.runtime`, so every pyAML read and write
8
+ passes through the OSPREY `ControlSystemConnector`: channel limits, write
9
+ posture, control-target pinning, write verification and audit all apply.
10
+ pyAML's native backends, which open their own control-system clients, are not
11
+ used.
12
+
13
+ The package ships with `osprey-framework`, which depends on it; it is not meant
14
+ to be installed on its own.
15
+
16
+ ## Selecting the backend
17
+
18
+ In a pyAML configuration:
19
+
20
+ ```yaml
21
+ controls:
22
+ - type: pyaml_cs_osprey.controlsystem
23
+ name: live
24
+ ```
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pyaml-cs-osprey"
7
+ dynamic = ["version"]
8
+ description = "pyAML control-system backend that routes every access through the OSPREY connector"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "BSD-3-Clause"}
12
+ # `osprey-framework` is deliberately not declared: the framework depends on this
13
+ # package, so declaring it back would be a cycle. The package is not meant to be
14
+ # installed without the framework, whose `osprey.runtime` it calls.
15
+ dependencies = [
16
+ # pyAML (imports as `pyaml`): the staging distribution, upstream 0.3.2
17
+ # plus the trajectory tool; never installed beside `accelerator-middle-layer`.
18
+ "accelerator-middle-layer-apg==0.3.2.post1",
19
+ # The backend raises and catches the connectors' own errors (device.py,
20
+ # devices.py, _run.py, errors.py). The same floor as the framework's
21
+ # `osprey-connectors` dependency (a test holds them together).
22
+ "osprey-connectors>=2026.11.11",
23
+ ]
24
+
25
+ [project.entry-points."pyaml.schemas"]
26
+ # A package value: pyAML's schema discovery imports the package and every submodule
27
+ # under it, and `@register_schema` in `controlsystem.py` registers the schema.
28
+ pyaml_cs_osprey = "pyaml_cs_osprey"
29
+
30
+ [tool.hatch.version]
31
+ source = "vcs"
32
+ # Versions with the framework's calendar stream, from the same v-tags and the
33
+ # same describe command as the framework (see the root pyproject.toml): the two
34
+ # wheels built from one checkout carry the same number.
35
+ raw-options = { root = "../..", version_scheme = "post-release", git_describe_command = "git describe --dirty --tags --long --abbrev=40 --match v[0-9]*" }
36
+
37
+ [tool.hatch.build.hooks.vcs]
38
+ version-file = "src/pyaml_cs_osprey/_version.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/pyaml_cs_osprey"]
@@ -0,0 +1,207 @@
1
+ """The process's pyAML run: where a pyAML write is accepted and recorded.
2
+
3
+ A pyAML write is an ordinary checked connector write
4
+ (``osprey.runtime.write_channel`` / ``write_channels``), accepted only while a
5
+ :func:`pyaml_cs_osprey.run_tool.run_tool` call is in progress in this process;
6
+ anywhere else it is refused as :data:`OUTSIDE_A_RUN` and nothing is read or
7
+ written. The run lists, in memory for the call's result, each address's value
8
+ before its first write. An address is listed unless the runtime showed its
9
+ write never went out (no attempt, or ``refused``); a write whose outcome is
10
+ unknown is listed. Nothing is written back.
11
+
12
+ The run belongs to the process, not to the thread that opened it: a
13
+ ``run_tool`` on any thread joins the one run, and the last one to leave ends
14
+ it. Its abort flag is a :class:`threading.Event`, so a ``SIGINT`` handler sets
15
+ it without taking :data:`_GUARD`, and every thread reads it.
16
+
17
+ A forked child starts with no run.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import os
24
+ import threading
25
+ from collections.abc import Callable, Iterator, Sequence
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ import osprey.runtime
30
+ from pyaml_cs_osprey.errors import OspreyWriteRefused
31
+
32
+ #: Why a device write outside a ``run_tool`` call is refused.
33
+ OUTSIDE_A_RUN = "pyAML writes run only inside pyaml_measure"
34
+
35
+ #: Held across one write's read, write and record, and over every change of
36
+ #: :data:`_RUN`. Nothing taken under it takes it again.
37
+ _GUARD = threading.Lock()
38
+
39
+
40
+ @dataclass
41
+ class _PyamlRun:
42
+ """The ``run_tool`` calls in progress in this process.
43
+
44
+ Attributes:
45
+ target: The control target whose run lock the run holds.
46
+ moved: Each address's value before its first write, in the order the
47
+ writes were made. An address is listed unless the runtime showed
48
+ its write never went out.
49
+ abort: A Ctrl-C asked every tool of the run to stop.
50
+ depth: How many ``run_tool`` calls are inside the run.
51
+ """
52
+
53
+ target: str
54
+ moved: dict[str, Any] = field(default_factory=dict)
55
+ abort: threading.Event = field(default_factory=threading.Event)
56
+ depth: int = 0
57
+
58
+
59
+ #: The process's run, ``None`` when no ``run_tool`` call is in progress.
60
+ _RUN: _PyamlRun | None = None
61
+
62
+
63
+ @contextlib.contextmanager
64
+ def opened(target: str) -> Iterator[None]:
65
+ """Be inside the process's run on *target* for the body, opening it when none is.
66
+
67
+ Raises:
68
+ RuntimeError: The process's run is on another target.
69
+ """
70
+ global _RUN
71
+ with _GUARD:
72
+ run = _RUN
73
+ if run is None:
74
+ run = _RUN = _PyamlRun(target=target)
75
+ elif run.target != target:
76
+ raise RuntimeError(
77
+ f"a pyAML run on {run.target} is in progress; it cannot open one on {target}"
78
+ )
79
+ run.depth += 1
80
+ try:
81
+ yield
82
+ finally:
83
+ with _GUARD:
84
+ run.depth -= 1
85
+ if run.depth == 0 and _RUN is run:
86
+ _RUN = None
87
+
88
+
89
+ def request_abort() -> bool:
90
+ """Ask every tool of the process's run to stop; whether a run is open."""
91
+ run = _RUN
92
+ if run is None:
93
+ return False
94
+ run.abort.set()
95
+ return True
96
+
97
+
98
+ def abort_requested() -> bool:
99
+ """Whether a Ctrl-C asked the process's run to stop."""
100
+ run = _RUN
101
+ return run is not None and run.abort.is_set()
102
+
103
+
104
+ def moved() -> dict[str, Any]:
105
+ """A copy of what the process's run moved.
106
+
107
+ Each address whose write may have gone out, with its value before its
108
+ first write.
109
+ """
110
+ with _GUARD:
111
+ run = _RUN
112
+ return {} if run is None else dict(run.moved)
113
+
114
+
115
+ def write(
116
+ addresses: Sequence[str],
117
+ write: Callable[..., object],
118
+ map_write: Callable[[Exception], Exception],
119
+ *,
120
+ map_read: Callable[[Exception, list[str]], Exception],
121
+ confirm: bool | None,
122
+ ) -> None:
123
+ """Write ``addresses`` inside the process's run only, recording what moved.
124
+
125
+ The addresses not yet listed are read in one ``read_channels`` call before
126
+ the write and listed with that value before the write is called. Once the
127
+ call ends, an address is taken off the list only when the runtime showed
128
+ its write never went out (no attempt, or ``refused``), whether or not the
129
+ call raised. An escape anywhere after the read, the write still running or
130
+ the list half pruned, leaves every address not yet taken off listed. An
131
+ escape that is not an ``Exception`` propagates unchanged. An address
132
+ already listed keeps its first value.
133
+
134
+ Args:
135
+ addresses: Every address the write touches.
136
+ write: The runtime write. It is called with ``confirm=confirm`` when
137
+ ``confirm`` is given and with no keyword otherwise, so each channel
138
+ resolves its own confirm default.
139
+ map_write: Maps an exception ``write`` raised onto the exception raised
140
+ in its place.
141
+ map_read: Maps an exception the pre-write read raised, with the
142
+ addresses asked for, onto the exception raised in its place.
143
+ confirm: Passed to ``write`` when not ``None``.
144
+
145
+ Raises:
146
+ OspreyWriteRefused: No ``run_tool`` call is in progress
147
+ (:data:`OUTSIDE_A_RUN`, naming the first address); nothing was read
148
+ or written.
149
+ Exception: ``map_read``'s mapping of a read failure, with nothing
150
+ written, or ``map_write``'s mapping of a write failure.
151
+ """
152
+ with _GUARD:
153
+ run = _RUN
154
+ if run is None:
155
+ raise OspreyWriteRefused(OUTSIDE_A_RUN, addresses[0] if addresses else None)
156
+ missing = [address for address in dict.fromkeys(addresses) if address not in run.moved]
157
+ pending: dict[str, Any] = {}
158
+ if missing:
159
+ try:
160
+ pending = dict(zip(missing, osprey.runtime.read_channels(missing), strict=True))
161
+ except Exception as exc:
162
+ raise map_read(exc, list(addresses)) from exc
163
+ run.moved.update(pending)
164
+ raised: BaseException | None = None
165
+ with osprey.runtime.record_writes() as records:
166
+ try:
167
+ write(**({} if confirm is None else {"confirm": confirm}))
168
+ except Exception as exc:
169
+ raised = exc
170
+ raise map_write(exc) from exc
171
+ except BaseException as exc:
172
+ raised = exc
173
+ raise
174
+ finally:
175
+ for address in pending:
176
+ if not _moved(address, records, raised):
177
+ del run.moved[address]
178
+
179
+
180
+ def _moved(address: str, records: Sequence[tuple[str, str]], raised: BaseException | None) -> bool:
181
+ """Whether ``address``'s write may have put its value on the control system.
182
+
183
+ An escape that is not an ``Exception``, such as a ``KeyboardInterrupt``
184
+ while the write keeps running elsewhere, keeps every address, failing
185
+ closed: the write may still go out. Otherwise the runtime's phases alone
186
+ decide: ``sent`` or ``landed`` moved it, ``refused`` and no ``attempt``
187
+ mean it never went out, and an ``attempt`` with no later phase is an
188
+ unknown outcome, kept.
189
+ """
190
+ if raised is not None and not isinstance(raised, Exception):
191
+ return True
192
+ phases = {phase for recorded, phase in records if recorded == address}
193
+ if phases & {"sent", "landed"}:
194
+ return True
195
+ if "refused" in phases:
196
+ return False
197
+ return "attempt" in phases
198
+
199
+
200
+ def _reset() -> None:
201
+ """Give a forked child no run and a guard of its own."""
202
+ global _GUARD, _RUN
203
+ _GUARD = threading.Lock()
204
+ _RUN = None
205
+
206
+
207
+ os.register_at_fork(after_in_child=_reset)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2026.11.0'
22
+ __version_tuple__ = version_tuple = (2026, 11, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,97 @@
1
+ """Parse ``pyaml-cs-oa`` channel references.
2
+
3
+ A pyAML configuration names each control-system channel with a short reference string.
4
+ The grammar, shared with ``pyaml-cs-oa``, is::
5
+
6
+ ADDR[unit] read-write: one address for both directions
7
+ (ADDR)[unit] write-only
8
+ (RB, SP)[unit] read-write: readback address RB, setpoint address SP
9
+ ...@i[unit] any of the above, addressing element ``i`` of an array channel
10
+
11
+ The ``[unit]`` suffix is optional; a missing unit is ``''``. Whitespace between parts is
12
+ ignored. An indexed reference parses (so the control system can refuse it by name) and
13
+ sets :attr:`ChannelReference.index`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Literal
21
+
22
+ from pyaml.common.exception import PyAMLException
23
+
24
+ __all__ = ["ChannelReference", "parse_reference"]
25
+
26
+ Mode = Literal["w", "rw"]
27
+
28
+ _ADDR = r"[^\s()\[\],@]+"
29
+ _REFERENCE_RE = re.compile(
30
+ rf"""
31
+ (?:
32
+ \(\s*(?P<rb>{_ADDR})\s*,\s*(?P<sp>{_ADDR})\s*\) # (RB, SP)
33
+ | \(\s*(?P<wo>{_ADDR})\s*\) # (ADDR)
34
+ | (?P<bare>{_ADDR}) # ADDR
35
+ )
36
+ (?:\s*@\s*(?P<index>\d+))?
37
+ (?:\s*\[\s*(?P<unit>[^\[\]]*?)\s*\])?
38
+ """,
39
+ re.VERBOSE,
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ChannelReference:
45
+ """One parsed channel reference.
46
+
47
+ Attributes:
48
+ text: The reference as written, surrounding whitespace removed; excluded from
49
+ equality, so two spellings of one reference compare equal.
50
+ address: The address this reference writes; a bare reference also reads it.
51
+ readback: The separate readback address of a read-write pair, else ``None``.
52
+ mode: ``"w"`` for a write-only reference, else ``"rw"``.
53
+ unit: The unit suffix, ``''`` when absent.
54
+ index: The array element index, ``None`` for a scalar reference.
55
+ """
56
+
57
+ text: str = field(compare=False)
58
+ address: str
59
+ readback: str | None
60
+ mode: Mode
61
+ unit: str
62
+ index: int | None
63
+
64
+
65
+ def parse_reference(text: object) -> ChannelReference:
66
+ """Parse a ``pyaml-cs-oa`` channel reference.
67
+
68
+ Args:
69
+ text: The reference string from a pyAML configuration; anything else is refused.
70
+
71
+ Returns:
72
+ The parsed reference.
73
+
74
+ Raises:
75
+ PyAMLException: The text does not follow the grammar; the message quotes it.
76
+ """
77
+ if not isinstance(text, str):
78
+ raise PyAMLException(f"channel reference must be a string, got {text!r}")
79
+ stripped = text.strip()
80
+ match = _REFERENCE_RE.fullmatch(stripped)
81
+ if match is None:
82
+ raise PyAMLException(
83
+ f"malformed channel reference {text!r}: expected ADDR[unit], (ADDR)[unit] "
84
+ "or (RB, SP)[unit], optionally with @index before the unit"
85
+ )
86
+ unit = match["unit"] or ""
87
+ index = int(match["index"]) if match["index"] is not None else None
88
+ address: str
89
+ readback: str | None = None
90
+ mode: Mode
91
+ if match["sp"] is not None:
92
+ address, readback, mode = match["sp"], match["rb"], "rw"
93
+ elif match["wo"] is not None:
94
+ address, mode = match["wo"], "w"
95
+ else:
96
+ address, mode = match["bare"], "rw"
97
+ return ChannelReference(stripped, address, readback, mode, unit, index)