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/qemu.py ADDED
@@ -0,0 +1,364 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """The QEMU/DOS guest binding.
4
+
5
+ `suite_backend()` guards the door with `binfmt.classify()`: a DOS
6
+ program — plain MZ or a headerless/.com image — yields a
7
+ QemuSuiteBackend; anything else is rejected before any guest work,
8
+ with the format and architecture named. The framework adapter
9
+ defaults to testaferro.cpputest.
10
+
11
+ QemuSuiteBackend drives a reliquary machine on the caller's behalf.
12
+ Each facade session gets a fresh, disposable reliquary home under
13
+ testaferro's cache directory (LOCALAPPDATA or XDG_CACHE_HOME): the
14
+ declaration is written there as a blueprint, reliquary creates and
15
+ boots one machine from it, and every guest run is one `reliquary.exec`
16
+ against that machine. The suite executable reaches the guest on a
17
+ host-directory (hostdir) work drive testaferro adds to the blueprint and
18
+ stages before boot. Zero configuration is seeded from the
19
+ caller-supplied `boot_image` or a cached FreeDOS image.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import atexit
25
+ import json
26
+ import os
27
+ import shutil
28
+ import tempfile
29
+
30
+ import reliquary
31
+
32
+ from . import binfmt
33
+ from . import cache
34
+ from . import cpputest
35
+ from .suite import SuiteBackend
36
+
37
+
38
+ # The blueprint name testaferro writes into each session's private
39
+ # asset root, and the machine created from it.
40
+ _BLUEPRINT_NAME = "testaferro"
41
+ # The host-directory drive carrying the suite executable to the guest.
42
+ _WORK_MEDIA_NAME = "testaferro-work"
43
+ _BOOT_MEDIA_NAME = "testaferro-boot"
44
+ _DEFAULT_MEMORY = "32M"
45
+ # Seconds one guest command may take before reliquary gives up.
46
+ _DEFAULT_TIMEOUT = 120
47
+ _HDD_SLOTS = 4
48
+
49
+ # FreeDOS 1.4's FloppyEdition, from which x86BOOT.img (a ready-to-boot
50
+ # 1.44M floppy image) is extracted; a reliquary media definition,
51
+ # fetched and hash-verified through reliquary.fetch_media().
52
+ _FREEDOS_FLOPPY_MEDIA_NAME = "freedos-boot-floppy"
53
+ _FREEDOS_FLOPPY_MEDIA_DEFINITION = [
54
+ {
55
+ "type": "media",
56
+ "name": "freedos-floppy-edition",
57
+ "location": "https://download.freedos.org/1.4/FD14-FloppyEdition.zip",
58
+ "sha256": ("45b1fa7c52dd996c3bfa5e352ffcd410781b952a6ad629f"
59
+ "15a4c9ec4bbaefc5a"),
60
+ "children": [
61
+ {
62
+ "type": "media",
63
+ "name": _FREEDOS_FLOPPY_MEDIA_NAME,
64
+ "path": "144m/x86BOOT.img",
65
+ "sha256": ("552f7cbb0625960c050a3e682a4d2121cc2ffdfa9dc9d59"
66
+ "2ccd49edf179333dc"),
67
+ },
68
+ ],
69
+ },
70
+ ]
71
+
72
+
73
+ def suite_backend(exe_path, framework=cpputest, enumerator=None,
74
+ boot_image=None, machine_config=None):
75
+ """Interrogate the referenced suite executable and return the
76
+ backend matching its format — a QemuSuiteBackend for a DOS
77
+ program. Raises FileNotFoundError for a missing file and
78
+ ValueError for a provably non-DOS executable (e.g. the suite's
79
+ host build passed by mistake)."""
80
+ exe_path = os.fspath(exe_path)
81
+ fmt = binfmt.classify(exe_path)
82
+ if fmt.platform != "dos":
83
+ raise ValueError(
84
+ f"{os.path.basename(exe_path)} is {fmt.kind} executable; "
85
+ "only DOS guest suites are supported")
86
+ if machine_config is not None:
87
+ from .machines import _coerce_machine_config
88
+
89
+ machine_config = _coerce_machine_config(machine_config)
90
+ if machine_config.platform != "dos":
91
+ raise ValueError(
92
+ "the QEMU/DOS binding requires a DOS machine config, "
93
+ f"not {machine_config.platform!r}")
94
+ if boot_image is not None and machine_config is not None:
95
+ raise TypeError("boot_image and machine_config cannot be combined")
96
+ return QemuSuiteBackend(exe_path, framework=framework,
97
+ enumerator=enumerator,
98
+ boot_image=boot_image,
99
+ machine_config=machine_config)
100
+
101
+
102
+ # The active testaferro session opened by start(), or None: its
103
+ # disposable directory (holding the staged boot image and every run
104
+ # home) plus the recorded image choice, staged lazily on first use.
105
+ _session = None
106
+
107
+ # Backends holding a booted machine right now. A machine outlives the
108
+ # call that started it, so an interpreter that goes down between
109
+ # start_session() and stop_session() would otherwise leave the guest
110
+ # running — and sweeping its home would pull the disk out from under
111
+ # it. Every exit path stops machines before deleting anything.
112
+ _running = set()
113
+ _sweep_registered = False
114
+
115
+
116
+ def _machine_started(backend):
117
+ """Record a backend whose machine is now running."""
118
+ global _sweep_registered
119
+ _running.add(backend)
120
+ if not _sweep_registered:
121
+ atexit.register(_stop_running_machines)
122
+ _sweep_registered = True
123
+
124
+
125
+ def _stop_running_machines():
126
+ """Stop every machine still running, best effort.
127
+
128
+ Called before any sweep and again at interpreter exit. One
129
+ backend failing to stop must not strand the others, so failures
130
+ are swallowed here; the machine's own home is removed regardless.
131
+ """
132
+ for backend in list(_running):
133
+ try:
134
+ backend.stop_session()
135
+ except Exception:
136
+ _running.discard(backend)
137
+
138
+
139
+ def start(boot_image=None):
140
+ """Open a testaferro session: one boot-image choice serving every
141
+ suite until stop(). The image itself — `boot_image` or the cached
142
+ default — is staged lazily on the first guest use, so calling
143
+ this from a conftest costs nothing when no guest test runs. An
144
+ atexit failsafe sweeps the session if stop() is never called."""
145
+ global _session
146
+ if _session is not None:
147
+ raise RuntimeError("a testaferro session is already active")
148
+ root = os.path.join(cache.cache_root(), "sessions")
149
+ os.makedirs(root, exist_ok=True)
150
+ _session = {
151
+ "dir": tempfile.mkdtemp(prefix="session-", dir=root),
152
+ "boot_image": (None if boot_image is None
153
+ else os.fspath(boot_image)),
154
+ }
155
+ # failsafe: sweep at interpreter exit if the caller never calls
156
+ # stop(); an explicit stop() unregisters this again
157
+ atexit.register(stop)
158
+
159
+
160
+ def stop(clear_downloads=False):
161
+ """Close the session opened by start(), sweeping its whole area —
162
+ staged image and every run home. Safe to call with no session
163
+ active. `clear_downloads=True` also removes the cached default
164
+ boot image, forcing a fresh download next time."""
165
+ global _session
166
+ atexit.unregister(stop)
167
+ # Machines first: the run homes about to be swept are the disks
168
+ # those guests are running from.
169
+ _stop_running_machines()
170
+ if _session is not None:
171
+ shutil.rmtree(_session["dir"], ignore_errors=True)
172
+ _session = None
173
+ if clear_downloads:
174
+ cached = os.path.join(cache.cache_root(), "boot.img")
175
+ for path in (cached, cached + ".part"):
176
+ if os.path.exists(path):
177
+ os.remove(path)
178
+
179
+
180
+ def _session_image():
181
+ """The active session's staged boot image, staging it on first
182
+ use from the recorded choice or the cached default."""
183
+ image = os.path.join(_session["dir"], "boot.img")
184
+ if not os.path.exists(image):
185
+ shutil.copy(_session["boot_image"] or _cached_default_image(),
186
+ image)
187
+ return image
188
+
189
+
190
+ def _cached_default_image():
191
+ """testaferro's cached copy of the default boot image: FreeDOS
192
+ 1.4's floppy-edition boot floppy, fetched and hash-verified
193
+ through reliquary.fetch_media() on first use."""
194
+ cached = os.path.join(cache.cache_root(), "boot.img")
195
+ if not os.path.exists(cached):
196
+ os.makedirs(cache.cache_root(), exist_ok=True)
197
+ with tempfile.TemporaryDirectory(
198
+ prefix="download-", dir=cache.cache_root()) as home:
199
+ assets = os.path.join(home, "assets")
200
+ os.makedirs(assets)
201
+ definition = os.path.join(assets, "freedos-floppy.rlqb")
202
+ with open(definition, "w", encoding="utf-8") as handle:
203
+ json.dump(_FREEDOS_FLOPPY_MEDIA_DEFINITION, handle)
204
+ payload = reliquary.fetch_media(
205
+ _FREEDOS_FLOPPY_MEDIA_NAME,
206
+ _context(home, assets))
207
+ shutil.copy(payload, cached + ".part")
208
+ os.replace(cached + ".part", cached)
209
+ return cached
210
+
211
+
212
+ def _context(home, assets):
213
+ """A reliquary context pinned to one disposable testaferro home.
214
+
215
+ Dir-mode assets keep the resolution hermetic: only what testaferro
216
+ wrote for this run, never the user's own reliquary home or the
217
+ built-in codex.
218
+ """
219
+ return reliquary.Context(home=home,
220
+ cache=os.path.join(home, "cache"),
221
+ assets=assets)
222
+
223
+
224
+ def _work_drive(drives):
225
+ """Place testaferro's work drive and name the letter DOS gives it.
226
+
227
+ reliquary assigns DOS letters from declared facts alone: floppies
228
+ take A:/B: by slot and hard disks C: onward in slot order. The
229
+ work drive takes the lowest free disk slot, so its letter is its
230
+ own position among the declared disks.
231
+ """
232
+ used = sorted({int(key[len("hdd"):] or 0)
233
+ for key in drives if key.startswith("hdd")})
234
+ free = [slot for slot in range(_HDD_SLOTS) if slot not in used]
235
+ if not free:
236
+ raise ValueError(
237
+ "the machine declares every disk slot; testaferro needs one "
238
+ "free slot for the work drive that carries the suite "
239
+ "executable into the guest")
240
+ slot = free[0]
241
+ letter = chr(ord("C") + sorted(used + [slot]).index(slot))
242
+ return f"hdd{slot}", letter
243
+
244
+
245
+ class QemuSuiteBackend(SuiteBackend):
246
+ def __init__(self, exe_path, framework=cpputest, enumerator=None,
247
+ boot_image=None, machine_config=None):
248
+ self._boot_image = (None if boot_image is None
249
+ else os.fspath(boot_image))
250
+ self._home = None
251
+ self._ctx = None
252
+ self._machine = None
253
+ self._letter = None
254
+ self._machine_config = machine_config
255
+ self._timeout = (
256
+ _DEFAULT_TIMEOUT if machine_config is None
257
+ or machine_config.timeout is None else machine_config.timeout)
258
+ super().__init__(os.fspath(exe_path), run=self._run_in_guest,
259
+ framework=framework, enumerator=enumerator)
260
+
261
+ def start_session(self):
262
+ area = _session["dir"] if _session else cache.cache_root()
263
+ runs = os.path.join(area, "runs")
264
+ os.makedirs(runs, exist_ok=True)
265
+ self._home = tempfile.mkdtemp(prefix="run-", dir=runs)
266
+ assets = os.path.join(self._home, "assets")
267
+ work = os.path.join(self._home, "work")
268
+ os.makedirs(assets)
269
+ os.makedirs(work)
270
+ # the backend snapshots the host directory when the drive is
271
+ # attached, so the executable is staged before the machine
272
+ # boots, not on the first run.
273
+ shutil.copy2(self._exe, os.path.join(work, self._program()))
274
+
275
+ document, self._letter = self._blueprint(work)
276
+ with open(os.path.join(assets, _BLUEPRINT_NAME + ".rlqb"),
277
+ "w", encoding="utf-8") as handle:
278
+ json.dump(document, handle)
279
+ self._ctx = _context(self._home, assets)
280
+ try:
281
+ self._machine = reliquary.create_machine(
282
+ _BLUEPRINT_NAME, context=self._ctx)
283
+ _machine_started(self)
284
+ reliquary.start_machine(self._machine, context=self._ctx)
285
+ except BaseException:
286
+ self.stop_session()
287
+ raise
288
+
289
+ def stop_session(self):
290
+ if self._home is None:
291
+ return
292
+ _running.discard(self)
293
+ try:
294
+ if self._machine is not None:
295
+ reliquary.stop_machine(self._machine,
296
+ context=self._ctx)
297
+ finally:
298
+ # The machine's own cache lives under this home, so the
299
+ # sweep takes the whole run with it.
300
+ shutil.rmtree(self._home, ignore_errors=True)
301
+ self._home = None
302
+ self._ctx = None
303
+ self._machine = None
304
+ self._letter = None
305
+
306
+ def _program(self):
307
+ """The suite's guest-side command name."""
308
+ return os.path.basename(self._exe)
309
+
310
+ def _run_in_guest(self, exe_path, args):
311
+ if self._home is None:
312
+ raise RuntimeError("no active session: guest runs happen "
313
+ "between start_session and stop_session")
314
+ command = f"{self._letter}:\\{self._program()}"
315
+ if args:
316
+ command += " " + " ".join(args)
317
+ rows = reliquary.exec(command, machine=self._machine,
318
+ context=self._ctx,
319
+ timeout=self._timeout)
320
+ return "\n".join(rows) + "\n"
321
+
322
+ def _blueprint(self, work):
323
+ """The blueprint document for this backend session.
324
+
325
+ The declaration (or the zero-configuration default) plus
326
+ testaferro's own work drive, which is how the suite executable
327
+ reaches the guest. Every drive is authored JSON passed through
328
+ to reliquary, which owns materialization: a declaration stays
329
+ a template because reliquary materializes a fresh machine from
330
+ it each session.
331
+ """
332
+ spec = self._machine_config
333
+ fields = dict(spec.fields) if spec is not None else {}
334
+ fields.setdefault("platform", "dos")
335
+ fields.setdefault("memory", _DEFAULT_MEMORY)
336
+ drives = dict(fields.get("drives") or {})
337
+ if not drives:
338
+ drives["floppy0"] = {
339
+ "type": "media",
340
+ "name": _BOOT_MEDIA_NAME,
341
+ "location": {"local": self._boot_media_path()},
342
+ "materialize": "use",
343
+ }
344
+ fields.setdefault("boot", ["floppy0"])
345
+ key, letter = _work_drive(drives)
346
+ drives[key] = {
347
+ "type": "media",
348
+ "name": _WORK_MEDIA_NAME,
349
+ "location": {"local": work},
350
+ "materialize": "use",
351
+ }
352
+ fields["drives"] = drives
353
+ fields["type"] = "machine"
354
+ fields["name"] = _BLUEPRINT_NAME
355
+ media = list(spec.media) if spec is not None else []
356
+ return [fields, *media], letter
357
+
358
+ def _boot_media_path(self):
359
+ """The image the zero-configuration machine boots: this
360
+ backend's own choice, the session's staged image, or the
361
+ cached default."""
362
+ if self._boot_image is not None:
363
+ return self._boot_image
364
+ return _session_image() if _session else _cached_default_image()
testaferro/suite.py ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """SuiteBackend: internal execution × framework composition.
4
+
5
+ The reliquary-backed platform binding supplies the execution callable;
6
+ the framework adapter exposes list_argv/run_all_argv/run_one_argv and
7
+ parse_list/parse_run (e.g. testaferro.cpputest). Neither aspect knows
8
+ the other exists. The callable remains an internal testing and
9
+ composition seam, not a public runner contract.
10
+
11
+ backend = SuiteBackend("VRING16.EXE",
12
+ run=run_guest_program,
13
+ framework=cpputest)
14
+
15
+ The optional `enumerator` replaces guest enumeration with a faster
16
+ source of the same list — e.g. a host-built twin executable — without
17
+ changing where the tests actually run.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .backend import Backend, TestId, TestOutcome
23
+
24
+
25
+ class SuiteBackend(Backend):
26
+ def __init__(self, exe_path, run, framework, enumerator=None):
27
+ self._exe = exe_path
28
+ self._run = run
29
+ self._framework = framework
30
+ self._enumerator = enumerator
31
+
32
+ def list_tests(self) -> "list[TestId]":
33
+ if self._enumerator is not None:
34
+ return self._enumerator()
35
+ return self._framework.parse_list(
36
+ self._run(self._exe, self._framework.list_argv()))
37
+
38
+ def run_test(self, group, name) -> TestOutcome:
39
+ outcomes = self._framework.parse_run(
40
+ self._run(self._exe,
41
+ self._framework.run_one_argv(group, name)))
42
+ for outcome in outcomes:
43
+ if (outcome.group, outcome.name) == (group, name):
44
+ return outcome
45
+ raise LookupError(
46
+ f"target did not run test {group}.{name} "
47
+ "(host and target test lists out of sync?)")
48
+
49
+ def run_all(self) -> "list[TestOutcome]":
50
+ return self._framework.parse_run(
51
+ self._run(self._exe, self._framework.run_all_argv()))
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: testaferro
3
+ Version: 0.1.0.dev0
4
+ Summary: pytest facade for DOS-based CppUTest unit testing, run via QEMU
5
+ Author-email: Paul Galbraith <paul@galbraiths.ca>
6
+ License-Expression: BSD-3-Clause
7
+ Keywords: pytest,cpputest,qemu,dos,testing,guest
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pytest
17
+ Requires-Dist: reliquary==0.1.0.dev2
18
+ Dynamic: license-file
19
+
20
+ # testaferro
21
+
22
+ testaferro is a pytest facade for DOS-based CppUTest unit testing: a CppUTest suite built for DOS runs inside a QEMU
23
+ guest via reliquary, and its tests surface as pytest tests on the host — running, selecting, and
24
+ reporting them feels like an ordinary local pytest run.
25
+
26
+ DOS and CppUTest are what it supports today. Reliquary owns the guest-machine side; testaferro owns the pytest facade and
27
+ its test-framework adapters. Other platforms and frameworks are not built; what has been argued for them, and what the
28
+ project has decided so far, is in [planning/](planning/).
29
+
30
+ ## Status: alpha
31
+
32
+ The architecture is built and works for its first target pair: a DOS-built CppUTest suite run inside a QEMU guest,
33
+ surfacing as ordinary pytest items on the host. It was verified end to end before the move onto reliquary's blueprint
34
+ model; since that move the unit suite passes but no guest has actually run, so treat a first run here as unproven
35
+ ground and expect to report what you find. testaferro is pre-1.0 and makes no compatibility promise: interfaces change
36
+ coherently and completely, without a bridge for the old shape.
37
+
38
+ ## Usage
39
+
40
+ Hand the facade a reference to the suite executable in a normal pytest test module:
41
+
42
+ ```python
43
+ from pathlib import Path
44
+
45
+ import testaferro
46
+
47
+ test_guest_case = testaferro.guest_suite(Path(__file__).parent / "TESTS.EXE")
48
+ ```
49
+
50
+ testaferro interrogates the referenced file and selects the matching guest backend: a DOS executable runs inside a
51
+ QEMU guest through reliquary (a dependency of testaferro), while a provably non-DOS binary — say, the host
52
+ build of the suite passed by mistake — is rejected with a clear error before any guest boots, naming the format and
53
+ architecture it found (Windows PE, Linux/BSD ELF, macOS Mach-O, 16-bit NE/LX/LE; x86 through ARM64). Headerless
54
+ images (`.com`-style raw code) carry nothing to prove, so they pass through for the guest itself to judge. The framework adapter
55
+ defaults to `testaferro.cpputest`; pass `framework=` to use a different one.
56
+
57
+ The guest machine's working state is testaferro's business, not the consumer's: each run happens in a fresh, disposable
58
+ reliquary home under testaferro's cache (`%LOCALAPPDATA%\testaferro` on Windows, `$XDG_CACHE_HOME/testaferro`
59
+ elsewhere), and the machine is created there fresh for the session and swept away with it. Zero configuration boots a
60
+ FreeDOS image that is downloaded once and cached; pass `boot_image=` to boot a caller-supplied DOS floppy image
61
+ instead.
62
+
63
+ The suite executable reaches the guest on a work drive testaferro adds to the machine: a host directory served into the
64
+ guest as a FAT volume, with the executable staged into it before boot. The guest sees it as its first hard disk —
65
+ normally `C:` — and testaferro runs it there by name. Nothing is written into your boot image.
66
+
67
+ ### Named test machines
68
+
69
+ Declare a named machine once when several suites share it. A declaration is a reliquary **blueprint** — the machine's
70
+ own description, in reliquary's vocabulary. `config()` accepts blueprint machine fields directly (`memory`, `drives`,
71
+ `boot`, `backend_settings`, …), or a complete `machine_config=` template: a `MachineSpec`, a mapping, a whole blueprint
72
+ document, or a path to a `.rlqb` file. The template supplies its platform when it declares one; `platform=` is optional
73
+ and verifies an explicit choice.
74
+
75
+ ```python
76
+ import testaferro
77
+
78
+ testaferro.config("msdos", boot_image="images/msdos.img", memory=32)
79
+
80
+ test_guest_case = testaferro.guest_suite(
81
+ "build/TESTS.EXE", machine="msdos")
82
+ ```
83
+
84
+ The same declarations can live in an optional per-project `testaferro.ini` — one section per machine, the
85
+ declarative twin of `config()`. `guest_suite()` searches upward from the calling test module and loads the file
86
+ automatically, so the suite can name only the executable when a unique machine matches:
87
+
88
+ ```ini
89
+ [msdos]
90
+ boot_image = images/msdos.img
91
+ memory = 32
92
+
93
+ [custom]
94
+ machine_config = machines/custom.rlqb
95
+ ```
96
+
97
+ ```python
98
+ test_guest_case = testaferro.guest_suite("build/TESTS.EXE")
99
+ ```
100
+
101
+ Relative `boot_image` / `machine_config` / `template` paths resolve from the ini file's directory. Structured blueprint
102
+ fields (`drives`, `boot`, `scripts`, `backend_settings`, `control_planes`, `parameters`) accept JSON values; a bare
103
+ integer stays an integer, so `memory = 32` and `memory = 32M` are both accepted. Call `testaferro.load_config(path)` to
104
+ load an explicit file, or `load_config()` to search upward from the current directory.
105
+
106
+ A declaration is a template, never a running machine: every backend session creates a fresh machine from it, so runs do
107
+ not share guest state. What that costs per session is the blueprint's own business — reliquary's `materialize` mode on
108
+ each drive decides whether media is attached in place, copied, or layered. Use `platform="dos"` or `machine="msdos"`
109
+ when more than one configured DOS machine would otherwise match. With no declarations, DOS executables retain the
110
+ implicit downloaded-FreeDOS machine.
111
+
112
+ With several guest suites — or parallel pytest processes — open a *session*, so the image choice is made once and
113
+ every run's state is swept together. From the consuming project's `conftest.py`:
114
+
115
+ ```python
116
+ import testaferro
117
+
118
+ testaferro.start() # or testaferro.start(boot_image=...)
119
+
120
+ def pytest_unconfigure(config):
121
+ testaferro.stop()
122
+ ```
123
+
124
+ `start()` costs nothing until a guest actually runs; `stop()` sweeps the session's staged image and every run home,
125
+ keeping the once-downloaded FreeDOS image cached for the next session (`stop(clear_downloads=True)` scrubs that too).
126
+ Forgetting `stop()` is not fatal — `start()` registers an `atexit` failsafe that sweeps the session at interpreter
127
+ exit — but the explicit call is still preferred: it cleans up at a deterministic point and is where
128
+ `clear_downloads=True` can be said.
129
+
130
+ Because every run gets a private home and a private image copy, suites in separate pytest processes never share
131
+ mutable guest state — safe to parallelize. With [pytest-xdist](https://pypi.org/project/pytest-xdist/), run
132
+ `pytest -n auto --dist loadfile`: `loadfile` keeps each test file's items on one worker, so a whole guest suite stays
133
+ together (preserving the one-boot `run_all()` batching) while *different* suites boot their guests concurrently on
134
+ other workers. Plain `--dist load` would scatter a suite's items across workers and degrade it to one boot per test.
135
+
136
+ Backend lifecycle hooks are automatic. Enumeration runs in a short collection session; selected tests then share one
137
+ execution session, which is cleaned up by pytest even when a test fails.
138
+
139
+ Every test in the guest suite becomes its own pytest item, so pytest's selection drives what actually runs remotely:
140
+
141
+ - run everything (`pytest`) and the facade batches the whole suite into a single guest run — one execution boot for the
142
+ session;
143
+ - narrow the selection (`pytest -k Wraps`, an explicit node id) and only the selected tests run in the guest,
144
+ individually.
145
+
146
+ A failing guest test fails its pytest item with the guest side's original file, line, and assertion message — not a
147
+ traceback into the facade. IDE test integrations work per item too: the generated test function reports the
148
+ `guest_suite()` call site as its source, so run-this-test and jump-to-source in PyCharm-style test trees resolve to
149
+ your module.
150
+
151
+ Under the hood, reliquary owns the guest machine and a framework adapter knows the suite's argv and output grammar.
152
+ `testaferro.suite.SuiteBackend` composes reliquary execution with the selected adapter; `guest_suite()` also accepts a
153
+ prebuilt backend as the custom escape hatch.
154
+
155
+ Enumeration can be delegated to a host-built twin of a guest suite through `enumerator=`, and for a suite of any size
156
+ it should be. Guest output is captured by reading the guest's screen, so a command whose output scrolls past one
157
+ screenful leaves only its tail there — which bites enumeration hardest, since `-ln` lists every test at once. A host
158
+ twin built from the same sources both avoids that and keeps the two builds honest against each other: a test the guest
159
+ build dropped shows up as one that did not run.
160
+
161
+ The framework adapter remains usable directly where the facade is more than you need — it is just argv and grammar,
162
+ independent of how you obtained the output:
163
+
164
+ ```python
165
+ from testaferro import cpputest
166
+
167
+ results = cpputest.parse(log) # {"ran", "failed", "summary"}
168
+ ```
169
+
170
+ ## Tests
171
+
172
+ ```powershell
173
+ python -m unittest discover -s tests -v
174
+ ```
175
+
176
+ ## Contributing
177
+
178
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the required checks, and contribution licensing terms.
179
+
180
+ ## License
181
+
182
+ BSD 3-Clause; see [LICENSE](LICENSE). The project follows REUSE conventions (SPDX headers
183
+ plus [REUSE.toml](REUSE.toml)).
@@ -0,0 +1,14 @@
1
+ testaferro/__init__.py,sha256=SndrEaMGYlN6l-yUC7e59gEufRuHjckHhN-zUzKmzZs,3038
2
+ testaferro/backend.py,sha256=k9Zg4SL7SZWDhXWOOWEeG_QFAEa2x2C_9GJlmfe6Ccc,1861
3
+ testaferro/binfmt.py,sha256=3npAcWwRxpn7OMF8M7okQIiWi1NhwS5mXwIpMdehgDs,4658
4
+ testaferro/cache.py,sha256=h9a7uxbCOsW_1sFJ-98o5o8kHjAqXURjLza2xONHx58,789
5
+ testaferro/cpputest.py,sha256=AUWTYBY7IAX14ckeORcEKa863AAofWrdbYdvy0i61nM,4639
6
+ testaferro/facade.py,sha256=pL8NX762S2NMvIVrqyCuMczJq2SfGzq7JdIAfJ7fh8Q,9440
7
+ testaferro/machines.py,sha256=7N6M_hpXCXKWlRqhrM018-KKJIX9ePXjc9I5dKwVgu8,15196
8
+ testaferro/qemu.py,sha256=g0etBiGCQQrziwiWaiWGlpajmsCiVCmzbmE3is1G-qM,14895
9
+ testaferro/suite.py,sha256=9KwDr71DgLuFBoExlDx8kB29iVOofF5phLuWPmLc24Y,1957
10
+ testaferro-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=PQZNmWpJyQDBeiUj6i7KYIamCtH1SzVXSW4Q4sTfA9c,1501
11
+ testaferro-0.1.0.dev0.dist-info/METADATA,sha256=zdkV3_3ZH4L7Nsd3HSBZRwGp_ARFVTgP3DVPXm8-Q7s,9602
12
+ testaferro-0.1.0.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ testaferro-0.1.0.dev0.dist-info/top_level.txt,sha256=tvsT9wvRXkh2cWG6ruTDM4xd0dsR5yzM2E0gHWF4pyc,11
14
+ testaferro-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Paul Galbraith
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,
9
+ this 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
22
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ testaferro