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/__init__.py +83 -0
- testaferro/backend.py +65 -0
- testaferro/binfmt.py +109 -0
- testaferro/cache.py +21 -0
- testaferro/cpputest.py +128 -0
- testaferro/facade.py +227 -0
- testaferro/machines.py +400 -0
- testaferro/qemu.py +364 -0
- testaferro/suite.py +51 -0
- testaferro-0.1.0.dev0.dist-info/METADATA +183 -0
- testaferro-0.1.0.dev0.dist-info/RECORD +14 -0
- testaferro-0.1.0.dev0.dist-info/WHEEL +5 -0
- testaferro-0.1.0.dev0.dist-info/licenses/LICENSE +29 -0
- testaferro-0.1.0.dev0.dist-info/top_level.txt +1 -0
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
|