nbs-bl 0.2.0__tar.gz → 0.2.1__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.
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/PKG-INFO +2 -1
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/pyproject.toml +15 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/beamline.py +4 -1
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/configuration.py +1 -2
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/xas.py +1 -1
- nbs_bl-0.2.1/src/nbs_bl/qt/models/config_model.py +384 -0
- nbs_bl-0.2.1/src/nbs_bl/qt/widgets/config_editor.py +770 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.github/workflows/python-publish.yml +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.gitignore +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.readthedocs.yaml +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/LICENSE +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/README.md +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/conf.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/configuration_reference.md +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/getting_started.md +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/index.rst +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/overview.md +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/requirements.txt +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/template_beamline.toml +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/template_devices.toml +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/detectors.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/detectors.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/motors.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/sampleholders.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/shutters.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/slits.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/gGrEqns.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/affine.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/bars.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/frames.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/linalg.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/polygons.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/help.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/hw.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/load.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/motors.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/planStatus.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/batches.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/conditions.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/flyscan_base.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/groups.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/maximizers.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/metaplans.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/plan_stubs.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/preprocessors.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scan_base.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scan_decorators.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scans.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/suspenders.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/time_estimation.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/printing.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/models/beamline.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/models/energy.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/widgets/energy.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/queueserver.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/redisDevice.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/run_engine.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/samples.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/settings.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/shutters.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/config/polphase.nc +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/energy.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/manipulator.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/utils.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/startup.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/status.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/__init__.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/modify_regions.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/test_frames.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/test_panels.py +0 -0
- {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nbs-bl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: NBS Beamline Framework
|
|
5
5
|
Project-URL: homepage, https://github.com/xraygui/nbs-bl
|
|
6
6
|
Author-email: Charles Titus <ctitus@bnl.gov>
|
|
@@ -12,6 +12,7 @@ Classifier: Operating System :: OS Independent
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Requires-Python: >=3.9
|
|
14
14
|
Requires-Dist: bluesky
|
|
15
|
+
Requires-Dist: bluesky-live
|
|
15
16
|
Requires-Dist: bluesky-queueserver
|
|
16
17
|
Requires-Dist: databroker>=2.0.0b67
|
|
17
18
|
Requires-Dist: ipython
|
|
@@ -20,6 +20,7 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
dependencies = ["bluesky",
|
|
22
22
|
"bluesky-queueserver",
|
|
23
|
+
"bluesky-live",
|
|
23
24
|
"databroker>=2.0.0b67",
|
|
24
25
|
"ophyd",
|
|
25
26
|
"numpy",
|
|
@@ -38,15 +39,29 @@ source = "vcs"
|
|
|
38
39
|
[project.entry-points."nbs_bl.plan_loaders"]
|
|
39
40
|
xas = "nbs_bl.plans.xas:load_xas"
|
|
40
41
|
|
|
42
|
+
[project.scripts]
|
|
43
|
+
nbs-bl-config-editor = "nbs_bl.qt.widgets.config_editor:main"
|
|
44
|
+
|
|
41
45
|
[tool.pixi.workspace]
|
|
42
46
|
channels = ["conda-forge"]
|
|
43
47
|
platforms = ["linux-64"]
|
|
44
48
|
|
|
49
|
+
[tool.pixi.pypi-dependencies]
|
|
50
|
+
nbs-bl = { path = ".", editable = true }
|
|
51
|
+
|
|
45
52
|
[tool.pixi.feature.build.dependencies]
|
|
46
53
|
hatch = "*"
|
|
47
54
|
|
|
48
55
|
[tool.pixi.feature.build.tasks]
|
|
49
56
|
build = "hatch build"
|
|
50
57
|
|
|
58
|
+
[tool.pixi.feature.gui.dependencies]
|
|
59
|
+
qtpy = "*"
|
|
60
|
+
pyqt = "*"
|
|
61
|
+
|
|
62
|
+
[tool.pixi.feature.gui.tasks]
|
|
63
|
+
config-editor = "nbs-bl-config-editor"
|
|
64
|
+
|
|
51
65
|
[tool.pixi.environments]
|
|
52
66
|
build = { features = ["build"], solve-group = "default" }
|
|
67
|
+
gui = { features = ["gui"], solve-group = "default" }
|
|
@@ -20,6 +20,7 @@ class BeamlineModel:
|
|
|
20
20
|
"mirrors",
|
|
21
21
|
"controllers",
|
|
22
22
|
"vacuum",
|
|
23
|
+
"source",
|
|
23
24
|
"misc",
|
|
24
25
|
]
|
|
25
26
|
|
|
@@ -31,6 +32,7 @@ class BeamlineModel:
|
|
|
31
32
|
"intensity_detector",
|
|
32
33
|
"primary_sampleholder",
|
|
33
34
|
"reference_sampleholder",
|
|
35
|
+
"mode",
|
|
34
36
|
"slits",
|
|
35
37
|
]
|
|
36
38
|
|
|
@@ -228,7 +230,8 @@ class BeamlineModel:
|
|
|
228
230
|
If loading the device fails
|
|
229
231
|
"""
|
|
230
232
|
if device_name not in self._deferred_devices:
|
|
231
|
-
|
|
233
|
+
print(f"Device {device_name} is not in deferred devices")
|
|
234
|
+
return
|
|
232
235
|
|
|
233
236
|
# If it's an alias, get and load the root device
|
|
234
237
|
config = self._deferred_config.get(device_name, {})
|
|
@@ -50,8 +50,7 @@ def load_and_configure_everything(startup_dir=None, initializer=None):
|
|
|
50
50
|
ip.user_ns["request_update"] = request_update
|
|
51
51
|
|
|
52
52
|
if initializer is None:
|
|
53
|
-
initializer =
|
|
54
|
-
ip.user_ns["beamline_initializer"] = initializer
|
|
53
|
+
initializer = get_default_initializer()
|
|
55
54
|
initializer.initialize(startup_dir, ip.user_ns)
|
|
56
55
|
|
|
57
56
|
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import tomllib
|
|
3
|
+
except ModuleNotFoundError:
|
|
4
|
+
import tomli as tomllib
|
|
5
|
+
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from qtpy.QtCore import QObject, Signal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ConfigIssue:
|
|
16
|
+
"""A validation issue found in a device configuration.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
device_key : str
|
|
21
|
+
The device table name in the TOML file.
|
|
22
|
+
message : str
|
|
23
|
+
A human-readable description of the issue.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
device_key: str
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigModel(QObject):
|
|
31
|
+
"""Model for editing NBS-BL device TOML configuration files.
|
|
32
|
+
|
|
33
|
+
This model is responsible for:
|
|
34
|
+
- Loading/parsing TOML into a Python mapping
|
|
35
|
+
- Serializing the mapping back to TOML
|
|
36
|
+
- Providing a small validation layer for NBS-BL special keys
|
|
37
|
+
- Translating special keys to/from human-readable GUI labels
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
This model does not attempt to preserve TOML comments. Saving will
|
|
42
|
+
rewrite the file content.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
changed = Signal()
|
|
46
|
+
issues_changed = Signal(list)
|
|
47
|
+
file_path_changed = Signal(object)
|
|
48
|
+
dirty_changed = Signal(bool)
|
|
49
|
+
|
|
50
|
+
SPECIAL_KEY_LABELS = {
|
|
51
|
+
"_target": "Target Class",
|
|
52
|
+
"_group": "Group",
|
|
53
|
+
"_role": "Role",
|
|
54
|
+
"_defer_loading": "Defer Loading",
|
|
55
|
+
"_add_to_ns": "Add to Namespace",
|
|
56
|
+
"_load_order": "Load Order",
|
|
57
|
+
"_baseline": "Baseline",
|
|
58
|
+
"_modes": "Modes",
|
|
59
|
+
"_alias": "Alias",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
SPECIAL_KEYS = set(SPECIAL_KEY_LABELS.keys())
|
|
63
|
+
|
|
64
|
+
def __init__(self, parent: QObject | None = None):
|
|
65
|
+
super().__init__(parent=parent)
|
|
66
|
+
self._devices: dict[str, dict[str, Any]] = {}
|
|
67
|
+
self._saved_devices: dict[str, dict[str, Any]] = {}
|
|
68
|
+
self._file_path: Path | None = None
|
|
69
|
+
self._issues: list[ConfigIssue] = []
|
|
70
|
+
self._label_to_key = {v: k for k, v in self.SPECIAL_KEY_LABELS.items()}
|
|
71
|
+
self._dirty = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def file_path(self) -> Path | None:
|
|
75
|
+
"""Path to the currently loaded file, if any."""
|
|
76
|
+
|
|
77
|
+
return self._file_path
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def devices(self) -> dict[str, dict[str, Any]]:
|
|
81
|
+
"""Return the device configuration mapping."""
|
|
82
|
+
|
|
83
|
+
return self._devices
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def issues(self) -> list[ConfigIssue]:
|
|
87
|
+
"""Return current validation issues."""
|
|
88
|
+
|
|
89
|
+
return self._issues
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def dirty(self) -> bool:
|
|
93
|
+
"""Whether the configuration has unsaved changes."""
|
|
94
|
+
|
|
95
|
+
return self._dirty
|
|
96
|
+
|
|
97
|
+
def is_device_dirty(self, device_key: str) -> bool:
|
|
98
|
+
"""Return whether a device has been modified since last load/save.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
device_key : str
|
|
103
|
+
Device key to check.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
bool
|
|
108
|
+
True if the device differs from the last saved snapshot.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
return self._devices.get(device_key, {}) != self._saved_devices.get(device_key, {})
|
|
112
|
+
|
|
113
|
+
def clear(self) -> None:
|
|
114
|
+
"""Clear all devices and reset file path."""
|
|
115
|
+
|
|
116
|
+
self._devices = {}
|
|
117
|
+
self._saved_devices = deepcopy(self._devices)
|
|
118
|
+
self._file_path = None
|
|
119
|
+
self._revalidate()
|
|
120
|
+
self._update_dirty()
|
|
121
|
+
self.file_path_changed.emit(self._file_path)
|
|
122
|
+
self.changed.emit()
|
|
123
|
+
|
|
124
|
+
def load_from_file(self, file_path: str | Path) -> None:
|
|
125
|
+
"""Load devices configuration from a TOML file.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
file_path : str or pathlib.Path
|
|
130
|
+
Path to a TOML file containing device tables.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
path = Path(file_path)
|
|
134
|
+
with path.open("rb") as f:
|
|
135
|
+
data = tomllib.load(f)
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
raise ValueError("Device config TOML must be a table of device tables")
|
|
138
|
+
devices: dict[str, dict[str, Any]] = {}
|
|
139
|
+
for key, value in data.items():
|
|
140
|
+
if isinstance(value, dict):
|
|
141
|
+
devices[str(key)] = dict(value)
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError(f"Device '{key}' must be a table")
|
|
144
|
+
self._devices = devices
|
|
145
|
+
self._saved_devices = deepcopy(self._devices)
|
|
146
|
+
self._file_path = path
|
|
147
|
+
self._revalidate()
|
|
148
|
+
self._update_dirty()
|
|
149
|
+
self.file_path_changed.emit(self._file_path)
|
|
150
|
+
self.changed.emit()
|
|
151
|
+
|
|
152
|
+
def save_to_file(self, file_path: str | Path | None = None) -> None:
|
|
153
|
+
"""Save devices configuration to a TOML file.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
file_path : str or pathlib.Path or None, optional
|
|
158
|
+
Output path. If None, uses the currently loaded file path.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
path = Path(file_path) if file_path is not None else self._file_path
|
|
162
|
+
if path is None:
|
|
163
|
+
raise ValueError("No file path provided for saving")
|
|
164
|
+
|
|
165
|
+
text = self.dumps()
|
|
166
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
path.write_text(text, encoding="utf-8")
|
|
168
|
+
self._file_path = path
|
|
169
|
+
self._saved_devices = deepcopy(self._devices)
|
|
170
|
+
self._update_dirty()
|
|
171
|
+
self.file_path_changed.emit(self._file_path)
|
|
172
|
+
|
|
173
|
+
def dumps(self) -> str:
|
|
174
|
+
"""Serialize current devices mapping to TOML text.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
str
|
|
179
|
+
TOML representation of the devices mapping.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
lines: list[str] = []
|
|
183
|
+
for device_key, config in self._devices.items():
|
|
184
|
+
lines.append(self._format_table_header(device_key))
|
|
185
|
+
for key, value in config.items():
|
|
186
|
+
lines.append(f"{self._format_key(key)} = {self._format_value(value)}")
|
|
187
|
+
lines.append("")
|
|
188
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
189
|
+
|
|
190
|
+
def device_keys(self) -> list[str]:
|
|
191
|
+
"""Return device keys in display order."""
|
|
192
|
+
|
|
193
|
+
return list(self._devices.keys())
|
|
194
|
+
|
|
195
|
+
def get_device_config(self, device_key: str) -> dict[str, Any]:
|
|
196
|
+
"""Get a shallow copy of a device configuration."""
|
|
197
|
+
|
|
198
|
+
return dict(self._devices.get(device_key, {}))
|
|
199
|
+
|
|
200
|
+
def set_device_config(self, device_key: str, config: dict[str, Any]) -> None:
|
|
201
|
+
"""Replace a device configuration entirely."""
|
|
202
|
+
|
|
203
|
+
self._devices[device_key] = dict(config)
|
|
204
|
+
self._revalidate()
|
|
205
|
+
self._update_dirty()
|
|
206
|
+
self.changed.emit()
|
|
207
|
+
|
|
208
|
+
def add_device(self, device_key: str) -> None:
|
|
209
|
+
"""Add a new device with an empty configuration."""
|
|
210
|
+
|
|
211
|
+
if device_key in self._devices:
|
|
212
|
+
raise ValueError(f"Device '{device_key}' already exists")
|
|
213
|
+
self._devices[device_key] = {}
|
|
214
|
+
self._revalidate()
|
|
215
|
+
self._update_dirty()
|
|
216
|
+
self.changed.emit()
|
|
217
|
+
|
|
218
|
+
def remove_device(self, device_key: str) -> None:
|
|
219
|
+
"""Remove a device from the configuration."""
|
|
220
|
+
|
|
221
|
+
self._devices.pop(device_key, None)
|
|
222
|
+
self._revalidate()
|
|
223
|
+
self._update_dirty()
|
|
224
|
+
self.changed.emit()
|
|
225
|
+
|
|
226
|
+
def set_value(self, device_key: str, key: str, value: Any) -> None:
|
|
227
|
+
"""Set a configuration key on a device."""
|
|
228
|
+
|
|
229
|
+
if device_key not in self._devices:
|
|
230
|
+
raise KeyError(device_key)
|
|
231
|
+
self._devices[device_key][key] = value
|
|
232
|
+
self._revalidate()
|
|
233
|
+
self._update_dirty()
|
|
234
|
+
self.changed.emit()
|
|
235
|
+
|
|
236
|
+
def unset_value(self, device_key: str, key: str) -> None:
|
|
237
|
+
"""Remove a configuration key from a device."""
|
|
238
|
+
|
|
239
|
+
if device_key not in self._devices:
|
|
240
|
+
raise KeyError(device_key)
|
|
241
|
+
self._devices[device_key].pop(key, None)
|
|
242
|
+
self._revalidate()
|
|
243
|
+
self._update_dirty()
|
|
244
|
+
self.changed.emit()
|
|
245
|
+
|
|
246
|
+
def special_key_to_label(self, key: str) -> str:
|
|
247
|
+
"""Convert a special TOML key to a GUI label."""
|
|
248
|
+
|
|
249
|
+
return self.SPECIAL_KEY_LABELS.get(key, key)
|
|
250
|
+
|
|
251
|
+
def label_to_special_key(self, label: str) -> str:
|
|
252
|
+
"""Convert a GUI label back to a TOML special key."""
|
|
253
|
+
|
|
254
|
+
return self._label_to_key.get(label, label)
|
|
255
|
+
|
|
256
|
+
def parse_value_text(self, text: str) -> Any:
|
|
257
|
+
"""Parse a user-provided value string into a TOML-compatible value.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
text : str
|
|
262
|
+
Text representing a TOML value.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
Any
|
|
267
|
+
Parsed value. If parsing fails, the original text is returned.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
stripped = text.strip()
|
|
271
|
+
if stripped == "":
|
|
272
|
+
return ""
|
|
273
|
+
try:
|
|
274
|
+
parsed = tomllib.loads(f"v = {stripped}\n")
|
|
275
|
+
return parsed["v"]
|
|
276
|
+
except Exception:
|
|
277
|
+
return text
|
|
278
|
+
|
|
279
|
+
def _revalidate(self) -> None:
|
|
280
|
+
issues = self.validate()
|
|
281
|
+
self._issues = issues
|
|
282
|
+
self.issues_changed.emit(issues)
|
|
283
|
+
|
|
284
|
+
def _update_dirty(self) -> None:
|
|
285
|
+
dirty = self._devices != self._saved_devices
|
|
286
|
+
if dirty != self._dirty:
|
|
287
|
+
self._dirty = dirty
|
|
288
|
+
self.dirty_changed.emit(dirty)
|
|
289
|
+
|
|
290
|
+
def validate(self) -> list[ConfigIssue]:
|
|
291
|
+
"""Validate device configurations for known special keys and types.
|
|
292
|
+
|
|
293
|
+
Returns
|
|
294
|
+
-------
|
|
295
|
+
list[ConfigIssue]
|
|
296
|
+
A list of issues found in the current configuration.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
issues: list[ConfigIssue] = []
|
|
300
|
+
for device_key, config in self._devices.items():
|
|
301
|
+
if not isinstance(config, dict):
|
|
302
|
+
issues.append(ConfigIssue(device_key, "Device table must be a mapping"))
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
special_keys = [k for k in config.keys() if isinstance(k, str) and k.startswith("_")]
|
|
306
|
+
for k in special_keys:
|
|
307
|
+
if k not in self.SPECIAL_KEYS:
|
|
308
|
+
issues.append(ConfigIssue(device_key, f"Unknown special key: {k}"))
|
|
309
|
+
|
|
310
|
+
has_target = "_target" in config
|
|
311
|
+
has_alias = "_alias" in config
|
|
312
|
+
if not has_target and not has_alias:
|
|
313
|
+
issues.append(ConfigIssue(device_key, "Missing Target Class or Alias"))
|
|
314
|
+
if has_target and not isinstance(config.get("_target"), str):
|
|
315
|
+
issues.append(ConfigIssue(device_key, "Target Class must be a string"))
|
|
316
|
+
if has_alias and not isinstance(config.get("_alias"), str):
|
|
317
|
+
issues.append(ConfigIssue(device_key, "Alias must be a string"))
|
|
318
|
+
|
|
319
|
+
if "_role" in config and not isinstance(config.get("_role"), str):
|
|
320
|
+
issues.append(ConfigIssue(device_key, "Role must be a string"))
|
|
321
|
+
|
|
322
|
+
if "_group" in config:
|
|
323
|
+
group_val = config.get("_group")
|
|
324
|
+
if isinstance(group_val, str):
|
|
325
|
+
pass
|
|
326
|
+
elif isinstance(group_val, list) and all(isinstance(x, str) for x in group_val):
|
|
327
|
+
pass
|
|
328
|
+
else:
|
|
329
|
+
issues.append(ConfigIssue(device_key, "Group must be a string or list of strings"))
|
|
330
|
+
|
|
331
|
+
if "_modes" in config:
|
|
332
|
+
modes_val = config.get("_modes")
|
|
333
|
+
if not (isinstance(modes_val, list) and all(isinstance(x, str) for x in modes_val)):
|
|
334
|
+
issues.append(ConfigIssue(device_key, "Modes must be a list of strings"))
|
|
335
|
+
|
|
336
|
+
if "_load_order" in config and not isinstance(config.get("_load_order"), int):
|
|
337
|
+
issues.append(ConfigIssue(device_key, "Load Order must be an integer"))
|
|
338
|
+
|
|
339
|
+
for bool_key, label in (
|
|
340
|
+
("_defer_loading", "Defer Loading"),
|
|
341
|
+
("_add_to_ns", "Add to Namespace"),
|
|
342
|
+
("_baseline", "Baseline"),
|
|
343
|
+
):
|
|
344
|
+
if bool_key in config and not isinstance(config.get(bool_key), bool):
|
|
345
|
+
issues.append(ConfigIssue(device_key, f"{label} must be a boolean"))
|
|
346
|
+
|
|
347
|
+
return issues
|
|
348
|
+
|
|
349
|
+
def _format_table_header(self, name: str) -> str:
|
|
350
|
+
if self._is_bare_key(name):
|
|
351
|
+
return f"[{name}]"
|
|
352
|
+
return f'[{self._format_string(name)}]'
|
|
353
|
+
|
|
354
|
+
def _format_key(self, key: str) -> str:
|
|
355
|
+
if self._is_bare_key(key):
|
|
356
|
+
return key
|
|
357
|
+
return self._format_string(key)
|
|
358
|
+
|
|
359
|
+
def _format_value(self, value: Any) -> str:
|
|
360
|
+
if isinstance(value, bool):
|
|
361
|
+
return "true" if value else "false"
|
|
362
|
+
if isinstance(value, int):
|
|
363
|
+
return str(value)
|
|
364
|
+
if isinstance(value, float):
|
|
365
|
+
return repr(value)
|
|
366
|
+
if isinstance(value, str):
|
|
367
|
+
return self._format_string(value)
|
|
368
|
+
if isinstance(value, list):
|
|
369
|
+
inner = ", ".join(self._format_value(v) for v in value)
|
|
370
|
+
return f"[{inner}]"
|
|
371
|
+
raise TypeError(f"Unsupported value type: {type(value).__name__}")
|
|
372
|
+
|
|
373
|
+
def _format_string(self, s: str) -> str:
|
|
374
|
+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
|
375
|
+
return f'"{escaped}"'
|
|
376
|
+
|
|
377
|
+
def _is_bare_key(self, key: str) -> bool:
|
|
378
|
+
if key == "":
|
|
379
|
+
return False
|
|
380
|
+
for ch in key:
|
|
381
|
+
if not (ch.isalnum() or ch in ("_", "-", ".")):
|
|
382
|
+
return False
|
|
383
|
+
return True
|
|
384
|
+
|