testaferro 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
testaferro/machines.py ADDED
@@ -0,0 +1,400 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """Named test-machine declarations backed by reliquary blueprints.
4
+
5
+ A declaration is reusable configuration, not a running machine: it is
6
+ the authored blueprint document reliquary materializes into a fresh
7
+ machine for every backend session. testaferro carries the authored
8
+ JSON through untouched and mirrors none of reliquary's schema —
9
+ validation happens when reliquary parses it.
10
+
11
+ Declarations come from ``configure()`` / ``testaferro.config()`` or
12
+ from an optional per-project ``testaferro.ini`` — one section per
13
+ machine, the declarative twin of ``configure()``. ``load_config()``
14
+ reads that file; ``guest_suite()`` searches upward from the call site
15
+ so test modules can name only the suite executable.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import collections.abc
21
+ import configparser
22
+ import json
23
+ import os
24
+ import types
25
+
26
+ # The .rlqb dialect (comments, trailing commas): a declaration is
27
+ # authored blueprint JSON, so it is read the way reliquary reads it.
28
+ from reliquary import jsonc
29
+
30
+
31
+ CONFIG_FILENAME = "testaferro.ini"
32
+
33
+ # Path-valued options resolved relative to the config file directory.
34
+ _PATH_KEYS = frozenset({"boot_image", "machine_config", "template"})
35
+ # Blueprint fields whose INI values are JSON (not bare strings).
36
+ _JSON_KEYS = frozenset({"drives", "boot", "scripts", "media",
37
+ "control_planes", "backend_settings",
38
+ "parameters"})
39
+ _FLOAT_KEYS = frozenset({"timeout"})
40
+ # Blueprint fields whose names hyphenate: written with underscores as
41
+ # a Python keyword or an INI option, stored as the blueprint spells
42
+ # them.
43
+ _HYPHENATED = types.MappingProxyType({
44
+ "control_planes": "control-planes",
45
+ "backend_settings": "backend-settings",
46
+ })
47
+
48
+ # Options testaferro consumes itself rather than passing to the
49
+ # blueprint: how long one guest command may take.
50
+ _TESTAFERRO_KEYS = frozenset({"timeout"})
51
+
52
+ _machines = {}
53
+ # None until the first load/search; then the absolute path or False
54
+ # when a search found no file.
55
+ _loaded_config = None
56
+
57
+
58
+ class MachineSpec:
59
+ """One declared test machine: an authored reliquary blueprint.
60
+
61
+ Immutable, and a template rather than a machine — the binding
62
+ writes it into a private reliquary home and creates a fresh
63
+ machine from it per session. Blueprint fields are readable as
64
+ attributes (``spec.platform``, ``spec.memory``, ``spec.drives``),
65
+ with underscores standing in for the hyphens the blueprint spells
66
+ (``spec.backend_settings``).
67
+ """
68
+
69
+ __slots__ = ("_machine", "_media", "timeout")
70
+
71
+ def __init__(self, machine, media=(), timeout=None):
72
+ fields = {_HYPHENATED.get(key, key): value
73
+ for key, value in machine.items()
74
+ if key not in ("type", "name")}
75
+ fields.setdefault("platform", "dos")
76
+ fields["platform"] = str(fields["platform"]).lower()
77
+ object.__setattr__(self, "_machine", types.MappingProxyType(fields))
78
+ object.__setattr__(self, "_media", tuple(dict(spec)
79
+ for spec in media))
80
+ object.__setattr__(self, "timeout", timeout)
81
+
82
+ def __getattr__(self, name):
83
+ try:
84
+ return self._machine[name.replace("_", "-")]
85
+ except KeyError:
86
+ raise AttributeError(
87
+ f"{type(self).__name__!r} declares no {name!r}") from None
88
+
89
+ def __setattr__(self, name, value):
90
+ raise AttributeError("a machine declaration is immutable")
91
+
92
+ def __repr__(self):
93
+ return f"MachineSpec({dict(self._machine)!r})"
94
+
95
+ @property
96
+ def fields(self):
97
+ """The authored machine spec, without its type/name identity."""
98
+ return self._machine
99
+
100
+ @property
101
+ def media(self):
102
+ """Media specs authored beside the machine, if any."""
103
+ return self._media
104
+
105
+ def document(self, name):
106
+ """The ``.rlqb`` document declaring this machine as ``name``."""
107
+ machine = dict(self._machine)
108
+ machine["type"] = "machine"
109
+ machine["name"] = name
110
+ return [machine, *(dict(spec) for spec in self._media)]
111
+
112
+
113
+ def configure(name, platform=None, machine_config=None, template=None,
114
+ boot_image=None, **options):
115
+ """Declare a named test machine and return its MachineSpec.
116
+
117
+ ``machine_config`` (or its ``template`` spelling) accepts a
118
+ MachineSpec, a mapping (a blueprint machine spec, or a whole
119
+ blueprint document), or the path to a ``.rlqb``. Without one, the
120
+ remaining options are the blueprint's own machine fields —
121
+ ``memory``, ``drives``, ``boot`` and friends. ``platform`` is an
122
+ optional consistency check for a supplied configuration and a
123
+ convenient field when constructing one here.
124
+ """
125
+ if not isinstance(name, str) or not name:
126
+ raise ValueError("machine name must be a non-empty string")
127
+ if name in _machines:
128
+ raise ValueError(f"test machine {name!r} is already configured")
129
+ if machine_config is not None and template is not None:
130
+ raise TypeError("pass either machine_config or template, not both")
131
+ if template is not None:
132
+ machine_config = template
133
+ if machine_config is not None and (options or boot_image is not None):
134
+ raise TypeError(
135
+ "machine_config is a complete template; pass its options "
136
+ "when creating the template")
137
+ if boot_image is not None and "drives" in options:
138
+ raise TypeError("boot_image and drives cannot be combined")
139
+
140
+ if machine_config is None:
141
+ fields = {key: value for key, value in options.items()
142
+ if key not in _TESTAFERRO_KEYS}
143
+ # `media` is a document-level spec, not a machine field: it
144
+ # declares media the drives refer to by name, and belongs
145
+ # beside the machine rather than inside it.
146
+ media = _media_specs(fields.pop("media", ()))
147
+ if boot_image is not None:
148
+ fields["drives"] = {"floppy0": _boot_media(boot_image)}
149
+ fields.setdefault("boot", ["floppy0"])
150
+ if platform is not None:
151
+ fields["platform"] = platform
152
+ machine_config = MachineSpec(fields, media,
153
+ timeout=options.get("timeout"))
154
+ else:
155
+ machine_config = _coerce_machine_config(machine_config, platform)
156
+ if (platform is not None
157
+ and machine_config.platform != str(platform).lower()):
158
+ raise ValueError(
159
+ f"machine {name!r} declares platform "
160
+ f"{machine_config.platform!r}, not {platform!r}")
161
+
162
+ _machines[name] = machine_config
163
+ return machine_config
164
+
165
+
166
+ def _media_specs(value):
167
+ """Normalize a declared ``media`` option to a list of specs.
168
+
169
+ One spec or several: a lone mapping is the list of one, which is
170
+ the natural thing to write for a machine needing a single named
171
+ medium.
172
+ """
173
+ if isinstance(value, collections.abc.Mapping):
174
+ return [value]
175
+ if isinstance(value, (str, bytes)) or not isinstance(
176
+ value, collections.abc.Iterable):
177
+ raise TypeError(
178
+ "media must be a blueprint media spec or a list of them")
179
+ return list(value)
180
+
181
+
182
+ def _boot_media(boot_image):
183
+ """The blueprint drive declaring a caller-supplied boot floppy."""
184
+ return {
185
+ "type": "media",
186
+ "name": "testaferro-boot",
187
+ "location": {"local": os.path.abspath(os.fspath(boot_image))},
188
+ "materialize": "use",
189
+ }
190
+
191
+
192
+ def configured():
193
+ """The declared machine names and immutable configurations."""
194
+ return dict(_machines)
195
+
196
+
197
+ def select(name=None, platform=None, inferred=None):
198
+ """Select a declared test machine, or return None for DOS's
199
+ zero-configuration default. Raises ValueError for absent or
200
+ ambiguous choices.
201
+ """
202
+ if name is not None:
203
+ try:
204
+ machine_config = _machines[name]
205
+ except KeyError:
206
+ raise ValueError(
207
+ f"unknown test machine {name!r}; configured: "
208
+ + _choices(_machines)) from None
209
+ if (platform is not None
210
+ and machine_config.platform != str(platform).lower()):
211
+ raise ValueError(
212
+ f"test machine {name!r} has platform "
213
+ f"{machine_config.platform!r}, not {platform!r}")
214
+ return name, machine_config
215
+
216
+ wanted = platform if platform is not None else inferred
217
+ if wanted is None:
218
+ return None
219
+ wanted = str(wanted).lower()
220
+ matches = [(machine_name, machine_config)
221
+ for machine_name, machine_config in _machines.items()
222
+ if machine_config.platform == wanted]
223
+ if len(matches) == 1:
224
+ return matches[0]
225
+ if len(matches) > 1:
226
+ raise ValueError(
227
+ f"platform {wanted!r} matches multiple test machines: "
228
+ + ", ".join(machine_name for machine_name, _ in matches))
229
+ if not _machines and wanted == "dos":
230
+ return None
231
+ raise ValueError(
232
+ f"no configured test machine for platform {wanted!r}; "
233
+ f"configured: {_choices(_machines)}")
234
+
235
+
236
+ def load_config(path=None, *, search_from=None):
237
+ """Load machine declarations from a local ``testaferro.ini``.
238
+
239
+ With ``path``, read that file (a missing file is an error). With
240
+ ``path`` omitted, search upward from ``search_from`` (default:
241
+ the current directory) for ``testaferro.ini`` and load it when
242
+ found; a fruitless search is a no-op and returns None.
243
+
244
+ Each section is one test machine — the same options as
245
+ ``configure()``. Relative ``boot_image``, ``machine_config``, and
246
+ ``template`` paths resolve from the file's directory. Structured
247
+ blueprint fields (``drives``, ``boot``, ``backend_settings`` and
248
+ friends) accept JSON values. Idempotent for a repeated load of the
249
+ same path or a repeated empty search.
250
+ """
251
+ global _loaded_config
252
+
253
+ if path is not None:
254
+ path = os.path.abspath(os.fspath(path))
255
+ if _loaded_config == path:
256
+ return path
257
+ if _loaded_config is not None:
258
+ raise RuntimeError(
259
+ "project config already loaded"
260
+ + (f" from {_loaded_config}"
261
+ if _loaded_config else " (no file found)"))
262
+ if not os.path.isfile(path):
263
+ raise FileNotFoundError(path)
264
+ _load_ini(path)
265
+ _loaded_config = path
266
+ return path
267
+
268
+ if _loaded_config is not None:
269
+ return _loaded_config if _loaded_config else None
270
+
271
+ start = os.getcwd() if search_from is None else os.fspath(search_from)
272
+ found = _find_config(start)
273
+ if found is None:
274
+ _loaded_config = False
275
+ return None
276
+ _load_ini(found)
277
+ _loaded_config = found
278
+ return found
279
+
280
+
281
+ def _find_config(start):
282
+ """Walk upward from start looking for CONFIG_FILENAME."""
283
+ directory = os.path.abspath(start)
284
+ if os.path.isfile(directory):
285
+ directory = os.path.dirname(directory)
286
+ while True:
287
+ candidate = os.path.join(directory, CONFIG_FILENAME)
288
+ if os.path.isfile(candidate):
289
+ return candidate
290
+ parent = os.path.dirname(directory)
291
+ if parent == directory:
292
+ return None
293
+ directory = parent
294
+
295
+
296
+ def _load_ini(path):
297
+ parser = configparser.ConfigParser(interpolation=None)
298
+ read = parser.read(path, encoding="utf-8")
299
+ if not read:
300
+ raise FileNotFoundError(path)
301
+ base_dir = os.path.dirname(path)
302
+ for name in parser.sections():
303
+ options = _section_options(parser[name], base_dir)
304
+ platform = options.pop("platform", None)
305
+ configure(name, platform=platform, **options)
306
+
307
+
308
+ def _section_options(section, base_dir):
309
+ if "machine_config" in section and "template" in section:
310
+ raise TypeError(
311
+ "pass either machine_config or template, not both")
312
+ options = {}
313
+ for key, raw in section.items():
314
+ value = _parse_option(key, raw)
315
+ if key in _PATH_KEYS and isinstance(value, str):
316
+ value = _resolve_path(base_dir, value)
317
+ options[key] = value
318
+ return options
319
+
320
+
321
+ def _parse_option(key, raw):
322
+ if key in _JSON_KEYS:
323
+ return json.loads(raw)
324
+ stripped = raw.strip()
325
+ if key in _FLOAT_KEYS:
326
+ return float(stripped)
327
+ # Sizes are written either way — `memory = 32` and `memory = 32M`
328
+ # are both blueprint-legal, so a bare integer stays an integer and
329
+ # anything else passes through as authored.
330
+ if stripped.lstrip("-").isdigit():
331
+ return int(stripped, 10)
332
+ # Allow JSON for any value that looks like a structured literal, so
333
+ # future blueprint fields stay expressible without a parser change.
334
+ if stripped[:1] in "[{":
335
+ return json.loads(stripped)
336
+ return raw
337
+
338
+
339
+ def _resolve_path(base_dir, value):
340
+ if os.path.isabs(value):
341
+ return value
342
+ return os.path.abspath(os.path.join(base_dir, value))
343
+
344
+
345
+ def _coerce_machine_config(value, platform=None):
346
+ if isinstance(value, MachineSpec):
347
+ return value
348
+ if isinstance(value, collections.abc.Mapping):
349
+ return _from_document(value, platform)
350
+ if isinstance(value, (str, os.PathLike)):
351
+ path = os.fspath(value)
352
+ with open(path, encoding="utf-8") as handle:
353
+ document = jsonc.load(handle)
354
+ return _from_document(document, platform)
355
+ if isinstance(value, collections.abc.Sequence):
356
+ return _from_document(list(value), platform)
357
+ raise TypeError(
358
+ "machine_config must be a MachineSpec, a blueprint mapping, "
359
+ "a blueprint document, or a path to one")
360
+
361
+
362
+ def _from_document(document, platform=None):
363
+ """Build a declaration from an authored blueprint or machine spec.
364
+
365
+ A ``.rlqb`` document is a list of specs; a lone spec object is the
366
+ document of one, which is also how a bare machine spec arrives from
367
+ ``configure()``. Exactly one machine must be present — a
368
+ declaration names one test machine.
369
+ """
370
+ specs = document if isinstance(document, list) else [document]
371
+ machines = [spec for spec in specs
372
+ if isinstance(spec, collections.abc.Mapping)
373
+ and spec.get("type", "media") == "machine"]
374
+ media = [spec for spec in specs
375
+ if isinstance(spec, collections.abc.Mapping)
376
+ and spec.get("type", "media") != "machine"]
377
+ if not machines and len(specs) == 1 and isinstance(
378
+ specs[0], collections.abc.Mapping):
379
+ # A bare machine spec, written without its `type` — the form
380
+ # configure() itself builds and the natural one to hand-write.
381
+ machines, media = [specs[0]], []
382
+ if len(machines) != 1:
383
+ raise ValueError(
384
+ f"a machine declaration needs exactly one machine spec, "
385
+ f"found {len(machines)}")
386
+ fields = dict(machines[0])
387
+ if platform is not None and "platform" not in fields:
388
+ fields["platform"] = platform
389
+ return MachineSpec(fields, media)
390
+
391
+
392
+ def _choices(values):
393
+ return ", ".join(values) if values else "(none)"
394
+
395
+
396
+ def _clear_for_tests():
397
+ """Clear declarations; private support for isolated unit tests."""
398
+ global _loaded_config
399
+ _machines.clear()
400
+ _loaded_config = None