testaferro 0.1.0.dev0__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.
@@ -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,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,164 @@
1
+ # testaferro
2
+
3
+ testaferro is a pytest facade for DOS-based CppUTest unit testing: a CppUTest suite built for DOS runs inside a QEMU
4
+ guest via reliquary, and its tests surface as pytest tests on the host — running, selecting, and
5
+ reporting them feels like an ordinary local pytest run.
6
+
7
+ DOS and CppUTest are what it supports today. Reliquary owns the guest-machine side; testaferro owns the pytest facade and
8
+ its test-framework adapters. Other platforms and frameworks are not built; what has been argued for them, and what the
9
+ project has decided so far, is in [planning/](planning/).
10
+
11
+ ## Status: alpha
12
+
13
+ The architecture is built and works for its first target pair: a DOS-built CppUTest suite run inside a QEMU guest,
14
+ surfacing as ordinary pytest items on the host. It was verified end to end before the move onto reliquary's blueprint
15
+ model; since that move the unit suite passes but no guest has actually run, so treat a first run here as unproven
16
+ ground and expect to report what you find. testaferro is pre-1.0 and makes no compatibility promise: interfaces change
17
+ coherently and completely, without a bridge for the old shape.
18
+
19
+ ## Usage
20
+
21
+ Hand the facade a reference to the suite executable in a normal pytest test module:
22
+
23
+ ```python
24
+ from pathlib import Path
25
+
26
+ import testaferro
27
+
28
+ test_guest_case = testaferro.guest_suite(Path(__file__).parent / "TESTS.EXE")
29
+ ```
30
+
31
+ testaferro interrogates the referenced file and selects the matching guest backend: a DOS executable runs inside a
32
+ QEMU guest through reliquary (a dependency of testaferro), while a provably non-DOS binary — say, the host
33
+ build of the suite passed by mistake — is rejected with a clear error before any guest boots, naming the format and
34
+ architecture it found (Windows PE, Linux/BSD ELF, macOS Mach-O, 16-bit NE/LX/LE; x86 through ARM64). Headerless
35
+ images (`.com`-style raw code) carry nothing to prove, so they pass through for the guest itself to judge. The framework adapter
36
+ defaults to `testaferro.cpputest`; pass `framework=` to use a different one.
37
+
38
+ The guest machine's working state is testaferro's business, not the consumer's: each run happens in a fresh, disposable
39
+ reliquary home under testaferro's cache (`%LOCALAPPDATA%\testaferro` on Windows, `$XDG_CACHE_HOME/testaferro`
40
+ elsewhere), and the machine is created there fresh for the session and swept away with it. Zero configuration boots a
41
+ FreeDOS image that is downloaded once and cached; pass `boot_image=` to boot a caller-supplied DOS floppy image
42
+ instead.
43
+
44
+ The suite executable reaches the guest on a work drive testaferro adds to the machine: a host directory served into the
45
+ guest as a FAT volume, with the executable staged into it before boot. The guest sees it as its first hard disk —
46
+ normally `C:` — and testaferro runs it there by name. Nothing is written into your boot image.
47
+
48
+ ### Named test machines
49
+
50
+ Declare a named machine once when several suites share it. A declaration is a reliquary **blueprint** — the machine's
51
+ own description, in reliquary's vocabulary. `config()` accepts blueprint machine fields directly (`memory`, `drives`,
52
+ `boot`, `backend_settings`, …), or a complete `machine_config=` template: a `MachineSpec`, a mapping, a whole blueprint
53
+ document, or a path to a `.rlqb` file. The template supplies its platform when it declares one; `platform=` is optional
54
+ and verifies an explicit choice.
55
+
56
+ ```python
57
+ import testaferro
58
+
59
+ testaferro.config("msdos", boot_image="images/msdos.img", memory=32)
60
+
61
+ test_guest_case = testaferro.guest_suite(
62
+ "build/TESTS.EXE", machine="msdos")
63
+ ```
64
+
65
+ The same declarations can live in an optional per-project `testaferro.ini` — one section per machine, the
66
+ declarative twin of `config()`. `guest_suite()` searches upward from the calling test module and loads the file
67
+ automatically, so the suite can name only the executable when a unique machine matches:
68
+
69
+ ```ini
70
+ [msdos]
71
+ boot_image = images/msdos.img
72
+ memory = 32
73
+
74
+ [custom]
75
+ machine_config = machines/custom.rlqb
76
+ ```
77
+
78
+ ```python
79
+ test_guest_case = testaferro.guest_suite("build/TESTS.EXE")
80
+ ```
81
+
82
+ Relative `boot_image` / `machine_config` / `template` paths resolve from the ini file's directory. Structured blueprint
83
+ fields (`drives`, `boot`, `scripts`, `backend_settings`, `control_planes`, `parameters`) accept JSON values; a bare
84
+ integer stays an integer, so `memory = 32` and `memory = 32M` are both accepted. Call `testaferro.load_config(path)` to
85
+ load an explicit file, or `load_config()` to search upward from the current directory.
86
+
87
+ A declaration is a template, never a running machine: every backend session creates a fresh machine from it, so runs do
88
+ not share guest state. What that costs per session is the blueprint's own business — reliquary's `materialize` mode on
89
+ each drive decides whether media is attached in place, copied, or layered. Use `platform="dos"` or `machine="msdos"`
90
+ when more than one configured DOS machine would otherwise match. With no declarations, DOS executables retain the
91
+ implicit downloaded-FreeDOS machine.
92
+
93
+ With several guest suites — or parallel pytest processes — open a *session*, so the image choice is made once and
94
+ every run's state is swept together. From the consuming project's `conftest.py`:
95
+
96
+ ```python
97
+ import testaferro
98
+
99
+ testaferro.start() # or testaferro.start(boot_image=...)
100
+
101
+ def pytest_unconfigure(config):
102
+ testaferro.stop()
103
+ ```
104
+
105
+ `start()` costs nothing until a guest actually runs; `stop()` sweeps the session's staged image and every run home,
106
+ keeping the once-downloaded FreeDOS image cached for the next session (`stop(clear_downloads=True)` scrubs that too).
107
+ Forgetting `stop()` is not fatal — `start()` registers an `atexit` failsafe that sweeps the session at interpreter
108
+ exit — but the explicit call is still preferred: it cleans up at a deterministic point and is where
109
+ `clear_downloads=True` can be said.
110
+
111
+ Because every run gets a private home and a private image copy, suites in separate pytest processes never share
112
+ mutable guest state — safe to parallelize. With [pytest-xdist](https://pypi.org/project/pytest-xdist/), run
113
+ `pytest -n auto --dist loadfile`: `loadfile` keeps each test file's items on one worker, so a whole guest suite stays
114
+ together (preserving the one-boot `run_all()` batching) while *different* suites boot their guests concurrently on
115
+ other workers. Plain `--dist load` would scatter a suite's items across workers and degrade it to one boot per test.
116
+
117
+ Backend lifecycle hooks are automatic. Enumeration runs in a short collection session; selected tests then share one
118
+ execution session, which is cleaned up by pytest even when a test fails.
119
+
120
+ Every test in the guest suite becomes its own pytest item, so pytest's selection drives what actually runs remotely:
121
+
122
+ - run everything (`pytest`) and the facade batches the whole suite into a single guest run — one execution boot for the
123
+ session;
124
+ - narrow the selection (`pytest -k Wraps`, an explicit node id) and only the selected tests run in the guest,
125
+ individually.
126
+
127
+ A failing guest test fails its pytest item with the guest side's original file, line, and assertion message — not a
128
+ traceback into the facade. IDE test integrations work per item too: the generated test function reports the
129
+ `guest_suite()` call site as its source, so run-this-test and jump-to-source in PyCharm-style test trees resolve to
130
+ your module.
131
+
132
+ Under the hood, reliquary owns the guest machine and a framework adapter knows the suite's argv and output grammar.
133
+ `testaferro.suite.SuiteBackend` composes reliquary execution with the selected adapter; `guest_suite()` also accepts a
134
+ prebuilt backend as the custom escape hatch.
135
+
136
+ Enumeration can be delegated to a host-built twin of a guest suite through `enumerator=`, and for a suite of any size
137
+ it should be. Guest output is captured by reading the guest's screen, so a command whose output scrolls past one
138
+ screenful leaves only its tail there — which bites enumeration hardest, since `-ln` lists every test at once. A host
139
+ twin built from the same sources both avoids that and keeps the two builds honest against each other: a test the guest
140
+ build dropped shows up as one that did not run.
141
+
142
+ The framework adapter remains usable directly where the facade is more than you need — it is just argv and grammar,
143
+ independent of how you obtained the output:
144
+
145
+ ```python
146
+ from testaferro import cpputest
147
+
148
+ results = cpputest.parse(log) # {"ran", "failed", "summary"}
149
+ ```
150
+
151
+ ## Tests
152
+
153
+ ```powershell
154
+ python -m unittest discover -s tests -v
155
+ ```
156
+
157
+ ## Contributing
158
+
159
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the required checks, and contribution licensing terms.
160
+
161
+ ## License
162
+
163
+ BSD 3-Clause; see [LICENSE](LICENSE). The project follows REUSE conventions (SPDX headers
164
+ plus [REUSE.toml](REUSE.toml)).
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ [build-system]
5
+ requires = ["setuptools>=77"]
6
+ build-backend = "setuptools.build_meta"
7
+
8
+ [project]
9
+ name = "testaferro"
10
+ version = "0.1.0.dev0"
11
+ description = "pytest facade for DOS-based CppUTest unit testing, run via QEMU"
12
+ readme = "README.md"
13
+ authors = [{ name = "Paul Galbraith", email = "paul@galbraiths.ca" }]
14
+ requires-python = ">=3.9"
15
+ license = "BSD-3-Clause"
16
+ license-files = ["LICENSE"]
17
+ keywords = ["pytest", "cpputest", "qemu", "dos", "testing", "guest"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Framework :: Pytest",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Software Development :: Testing",
24
+ ]
25
+ # pytest is the facade's host surface (testaferro.facade). QemuSuiteBackend
26
+ # requires reliquary, which is included as a dependency.
27
+ dependencies = ["pytest", "reliquary==0.1.0.dev2"]
28
+
29
+ [tool.setuptools]
30
+ packages = ["testaferro"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,83 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """testaferro: a pytest facade for remote (e.g. QEMU-guest) unit
4
+ tests.
5
+
6
+ A suite compiled for and running on a remote target surfaces as
7
+ ordinary pytest tests on the host. Hand guest_suite() a reference to
8
+ the suite executable:
9
+
10
+ from pathlib import Path
11
+
12
+ import testaferro
13
+
14
+ test_guest_case = testaferro.guest_suite(
15
+ Path(__file__).parent / "SUITE.EXE")
16
+
17
+ The executable is interrogated to select its platform (DOS programs
18
+ run in a QEMU guest; anything else is rejected), the
19
+ framework adapter defaults to testaferro.cpputest (`framework=`
20
+ overrides), and the runner's working state lives in
21
+ testaferro-managed disposable directories. Named test machines are
22
+ declared with config() or an optional per-project testaferro.ini; a
23
+ prebuilt Backend remains the custom escape hatch for callers that
24
+ need a different execution mechanism.
25
+
26
+ For many suites (and future parallel runs), open a session so the
27
+ boot image is specified once and all per-run state is swept together
28
+ — in pytest, from the consumer's conftest.py:
29
+
30
+ import testaferro
31
+
32
+ testaferro.start() # or start(boot_image=...)
33
+
34
+ def pytest_unconfigure(config):
35
+ testaferro.stop()
36
+ """
37
+
38
+ # eager: the facade's own imports are stdlib-only (pytest is loaded
39
+ # lazily inside it). start/stop delegate lazily instead, because
40
+ # importing testaferro.qemu pulls in reliquary.
41
+ from .facade import guest_suite # noqa: F401
42
+
43
+
44
+ def config(machine, platform=None, **options):
45
+ """Declare a named reliquary test machine.
46
+
47
+ ``platform`` is optional when the supplied ``machine_config`` or
48
+ ``template`` declares it. Without a template, remaining options are
49
+ the blueprint's own machine fields. The declaration is reused
50
+ as a template; each guest session receives a fresh materialization.
51
+ The same declarations may be written in ``testaferro.ini`` (see
52
+ ``load_config``).
53
+ """
54
+ from .machines import configure
55
+ return configure(machine, platform=platform, **options)
56
+
57
+
58
+ def load_config(path=None):
59
+ """Load named machines from a ``testaferro.ini``.
60
+
61
+ With ``path``, read that file. With ``path`` omitted, search
62
+ upward from the current directory. ``guest_suite()`` performs the
63
+ same search from its call site automatically.
64
+ """
65
+ from .machines import load_config as load
66
+ return load(path)
67
+
68
+
69
+ def start(boot_image=None):
70
+ """Open a guest-test session: one boot-image choice (or the
71
+ downloaded default) serving every suite until stop(). Costs
72
+ nothing until a guest actually runs; an atexit failsafe sweeps
73
+ the session if stop() is never called."""
74
+ from . import qemu
75
+ qemu.start(boot_image=boot_image)
76
+
77
+
78
+ def stop(clear_downloads=False):
79
+ """Close the session, sweeping its staged image and all per-run
80
+ state; safe without an active session. `clear_downloads=True`
81
+ also drops the cached default boot image."""
82
+ from . import qemu
83
+ qemu.stop(clear_downloads=clear_downloads)
@@ -0,0 +1,65 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """The Backend seam: what the pytest facade needs from any remote
4
+ test target.
5
+
6
+ Any target that can list its tests and run one or all of them plugs
7
+ into the same facade, however it carries those operations out
8
+ underneath. Session hooks default to no-ops because one-shot backends
9
+ (e.g. a SuiteBackend whose runner boots per operation)
10
+ have no session to manage.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TestId:
21
+ __test__ = False # not a test case, despite the name
22
+
23
+ group: str
24
+ name: str
25
+
26
+ def __str__(self):
27
+ return f"{self.group}.{self.name}"
28
+
29
+
30
+ @dataclass
31
+ class TestOutcome:
32
+ __test__ = False # not a test case, despite the name
33
+
34
+ group: str
35
+ name: str
36
+ passed: bool
37
+ file: str = ""
38
+ line: int = 0
39
+ message: str = ""
40
+
41
+
42
+ class Backend(ABC):
43
+ def start_session(self):
44
+ """Boot or attach to the remote target. Ready to accept
45
+ list/run calls immediately after this returns."""
46
+
47
+ @abstractmethod
48
+ def list_tests(self) -> "list[TestId]":
49
+ ...
50
+
51
+ @abstractmethod
52
+ def run_test(self, group, name) -> TestOutcome:
53
+ """Run one test. Raises LookupError if the target did not
54
+ run it (e.g. host and target test lists diverged)."""
55
+
56
+ @abstractmethod
57
+ def run_all(self) -> "list[TestOutcome]":
58
+ """Used by the facade's batching logic when no host-side
59
+ filter is active - a single bulk operation is expected to be
60
+ cheaper than many individual run_test() calls, however the
61
+ backend chooses to implement that."""
62
+
63
+ def stop_session(self):
64
+ """Tear down cleanly. Must be safe to call after a failed or
65
+ partial start_session()."""
@@ -0,0 +1,109 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """Executable-format classification for platform dispatch.
4
+
5
+ `classify()` reads an executable's header and reports which platform
6
+ could run it, without importing any runner:
7
+
8
+ - ``Format("dos", ...)`` — a plain MZ program, or a headerless image
9
+ (`.com`-style raw code carries nothing to prove, so it passes
10
+ through for the guest itself to judge);
11
+ - ``Format(None, ...)`` — a provably unsupported binary: PE,
12
+ NE/LX/LE, ELF, Mach-O (including universal).
13
+
14
+ `kind` always carries the human-readable format-and-architecture
15
+ name ('a Windows x64 (PE)', 'an ELF x86-64 (Linux/BSD)', ...) for
16
+ error messages, and a future platform extends the classification by
17
+ claiming formats currently mapped to None. Stdlib-only: the facade's
18
+ dispatch and each platform binding's own guard share it without
19
+ pulling in a runner.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import collections
25
+
26
+ Format = collections.namedtuple("Format", ["platform", "kind"])
27
+
28
+ _DOS_MZ = Format("dos", "a DOS MZ")
29
+ _HEADERLESS = Format("dos", "a headerless (.com-style)")
30
+
31
+ # architecture names per format, keyed by each format's machine field
32
+ _ELF_MACHINES = {0x03: "x86", 0x28: "ARM", 0x3E: "x86-64",
33
+ 0xB7: "ARM64", 0xF3: "RISC-V"}
34
+ _PE_MACHINES = {0x014C: "x86", 0x01C0: "ARM", 0x01C4: "ARM",
35
+ 0x8664: "x64", 0xAA64: "ARM64"}
36
+ _MACHO_CPUTYPES = {7: "x86", 0x01000007: "x86-64",
37
+ 12: "ARM", 0x0100000C: "ARM64"}
38
+
39
+
40
+ def _elf_format(header):
41
+ """Kind string for an ELF image (Linux and the BSDs; most carry
42
+ the generic System V OS/ABI byte, so no OS is claimed)."""
43
+ arch = None
44
+ if len(header) >= 0x14:
45
+ order = "little" if header[5] == 1 else "big"
46
+ arch = _ELF_MACHINES.get(
47
+ int.from_bytes(header[0x12:0x14], order))
48
+ return (f"an ELF {arch} (Linux/BSD)" if arch
49
+ else "an ELF (Linux/BSD)")
50
+
51
+
52
+ def _macho_format(header):
53
+ """Kind string for a Mach-O image, or None when the magic is
54
+ really something else's."""
55
+ magic = header[:4]
56
+ if magic in (b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf"):
57
+ # a universal binary's big-endian arch count is tiny; a Java
58
+ # class file shares the magic but puts its version there
59
+ if (len(header) >= 8
60
+ and int.from_bytes(header[4:8], "big") < 0x40):
61
+ return "a macOS universal (Mach-O)"
62
+ return None
63
+ order = "big" if magic[:2] == b"\xfe\xed" else "little"
64
+ arch = (_MACHO_CPUTYPES.get(int.from_bytes(header[4:8], order))
65
+ if len(header) >= 8 else None)
66
+ return (f"a macOS {arch} (Mach-O)" if arch
67
+ else "a macOS (Mach-O)")
68
+
69
+
70
+ def _mz_extension_format(found):
71
+ """The Format of the newer-format header an extended MZ file
72
+ points at through e_lfanew, or None when none is recognized
73
+ there. DOS runs none of these."""
74
+ if found.startswith(b"PE\0\0"):
75
+ arch = (_PE_MACHINES.get(int.from_bytes(found[4:6], "little"))
76
+ if len(found) >= 6 else None)
77
+ return Format(None, f"a Windows {arch} (PE)" if arch
78
+ else "a Windows (PE)")
79
+ for signature, kind in ((b"NE", "a 16-bit Windows or OS/2 (NE)"),
80
+ (b"LX", "an OS/2 (LX)"),
81
+ (b"LE", "a linear (LE)")):
82
+ if found.startswith(signature):
83
+ return Format(None, kind)
84
+ return None
85
+
86
+
87
+ def classify(exe_path):
88
+ """The Format of the referenced executable: which platform could
89
+ run it (`platform`) and its human-readable kind. Raises
90
+ FileNotFoundError for a missing file; attaches no meaning beyond
91
+ the header — a "dos" verdict means "nothing proves otherwise"."""
92
+ with open(exe_path, "rb") as f:
93
+ header = f.read(0x40)
94
+ if header[:4] == b"\x7fELF":
95
+ return Format(None, _elf_format(header))
96
+ if header[:4] in (b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
97
+ b"\xce\xfa\xed\xfe", b"\xcf\xfa\xed\xfe",
98
+ b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf"):
99
+ kind = _macho_format(header)
100
+ return _HEADERLESS if kind is None else Format(None, kind)
101
+ if header[:2] != b"MZ":
102
+ return _HEADERLESS
103
+ if len(header) < 0x40:
104
+ return _DOS_MZ
105
+ if int.from_bytes(header[0x18:0x1A], "little") < 0x40:
106
+ return _DOS_MZ
107
+ f.seek(int.from_bytes(header[0x3C:0x40], "little"))
108
+ found = f.read(8)
109
+ return _mz_extension_format(found) or _DOS_MZ