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.
- pyaml_cs_osprey-2026.11.0/.gitignore +226 -0
- pyaml_cs_osprey-2026.11.0/LICENSE +28 -0
- pyaml_cs_osprey-2026.11.0/PKG-INFO +35 -0
- pyaml_cs_osprey-2026.11.0/README.md +24 -0
- pyaml_cs_osprey-2026.11.0/pyproject.toml +41 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/__init__.py +0 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/_run.py +207 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/_version.py +24 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/catalog.py +97 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/controlsystem.py +80 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/device.py +349 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/devices.py +394 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/errors.py +182 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/measure.py +420 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/names.py +319 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/run_tool.py +314 -0
- pyaml_cs_osprey-2026.11.0/src/pyaml_cs_osprey/units.py +122 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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)
|