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.
Files changed (76) hide show
  1. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/PKG-INFO +2 -1
  2. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/pyproject.toml +15 -0
  3. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/beamline.py +4 -1
  4. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/configuration.py +1 -2
  5. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/xas.py +1 -1
  6. nbs_bl-0.2.1/src/nbs_bl/qt/models/config_model.py +384 -0
  7. nbs_bl-0.2.1/src/nbs_bl/qt/widgets/config_editor.py +770 -0
  8. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.github/workflows/python-publish.yml +0 -0
  9. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.gitignore +0 -0
  10. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/.readthedocs.yaml +0 -0
  11. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/LICENSE +0 -0
  12. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/README.md +0 -0
  13. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/conf.py +0 -0
  14. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/configuration_reference.md +0 -0
  15. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/getting_started.md +0 -0
  16. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/index.rst +0 -0
  17. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/overview.md +0 -0
  18. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/requirements.txt +0 -0
  19. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/template_beamline.toml +0 -0
  20. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/docs/template_devices.toml +0 -0
  21. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/__init__.py +0 -0
  22. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/detectors.py +0 -0
  23. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/__init__.py +0 -0
  24. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/detectors.py +0 -0
  25. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/motors.py +0 -0
  26. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/sampleholders.py +0 -0
  27. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/shutters.py +0 -0
  28. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/devices/slits.py +0 -0
  29. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/gGrEqns.py +0 -0
  30. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/__init__.py +0 -0
  31. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/affine.py +0 -0
  32. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/bars.py +0 -0
  33. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/frames.py +0 -0
  34. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/linalg.py +0 -0
  35. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/geometry/polygons.py +0 -0
  36. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/help.py +0 -0
  37. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/hw.py +0 -0
  38. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/load.py +0 -0
  39. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/motors.py +0 -0
  40. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/planStatus.py +0 -0
  41. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/__init__.py +0 -0
  42. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/batches.py +0 -0
  43. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/conditions.py +0 -0
  44. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/flyscan_base.py +0 -0
  45. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/groups.py +0 -0
  46. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/maximizers.py +0 -0
  47. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/metaplans.py +0 -0
  48. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/plan_stubs.py +0 -0
  49. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/preprocessors.py +0 -0
  50. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scan_base.py +0 -0
  51. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scan_decorators.py +0 -0
  52. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/scans.py +0 -0
  53. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/suspenders.py +0 -0
  54. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/plans/time_estimation.py +0 -0
  55. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/printing.py +0 -0
  56. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/models/beamline.py +0 -0
  57. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/models/energy.py +0 -0
  58. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/qt/widgets/energy.py +0 -0
  59. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/queueserver.py +0 -0
  60. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/redisDevice.py +0 -0
  61. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/run_engine.py +0 -0
  62. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/samples.py +0 -0
  63. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/settings.py +0 -0
  64. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/shutters.py +0 -0
  65. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/__init__.py +0 -0
  66. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/config/polphase.nc +0 -0
  67. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/energy.py +0 -0
  68. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/manipulator.py +0 -0
  69. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/sim/utils.py +0 -0
  70. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/startup.py +0 -0
  71. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/status.py +0 -0
  72. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/__init__.py +0 -0
  73. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/modify_regions.py +0 -0
  74. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/test_frames.py +0 -0
  75. {nbs_bl-0.2.0 → nbs_bl-0.2.1}/src/nbs_bl/tests/test_panels.py +0 -0
  76. {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.0
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
- raise KeyError(f"Device {device_name} is not in deferred devices")
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 = BeamlineInitializer(GLOBAL_BEAMLINE)
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
 
@@ -120,4 +120,4 @@ def load_xas(filename):
120
120
  user_ns[key] = xas_func
121
121
 
122
122
  # Return the generated plans dictionary in case it's needed
123
- return generated_plans
123
+ return list(generated_plans.keys())
@@ -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
+