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 ADDED
@@ -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)
testaferro/backend.py ADDED
@@ -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()."""
testaferro/binfmt.py ADDED
@@ -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
testaferro/cache.py ADDED
@@ -0,0 +1,21 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """testaferro's durable filespace, shared by the guest bindings."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+
9
+
10
+ def cache_root():
11
+ """testaferro's own filespace: each guest binding's image cache
12
+ plus its disposable per-session runner homes (under runs/; stale
13
+ ones from killed processes can be deleted freely)."""
14
+ if os.name == "nt":
15
+ base = (os.environ.get("LOCALAPPDATA")
16
+ or os.path.join(os.path.expanduser("~"),
17
+ "AppData", "Local"))
18
+ else:
19
+ base = (os.environ.get("XDG_CACHE_HOME")
20
+ or os.path.join(os.path.expanduser("~"), ".cache"))
21
+ return os.path.join(base, "testaferro")
testaferro/cpputest.py ADDED
@@ -0,0 +1,128 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """CppUTest framework adapter.
4
+
5
+ Knows CppUTest and nothing else: the argv that makes a suite
6
+ executable enumerate or run tests, and the grammar of the resulting
7
+ output. How and where the executable runs — the guest-OS aspect — is
8
+ deliberately not this module's business; the
9
+ two aspects compose in a SuiteBackend (testaferro.suite) or directly
10
+ at the call site:
11
+
12
+ log = run_guest_program(exe, args=cpputest.VERBOSE_ARGS)
13
+ results = cpputest.parse(log)
14
+
15
+ Output grammars follow CppUTest v4.0's own source (TestOutput.cpp,
16
+ TestRegistry.cpp): eclipse-style failure locations (the default),
17
+ '-ln' space-separated 'Group.Name' enumeration.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from .backend import TestId, TestOutcome
25
+
26
+ # Suite argv for each backend operation. VERBOSE_ARGS produces the
27
+ # run output parse()/parse_run() understand; LIST_ARGS produces the
28
+ # enumeration parse_list() understands.
29
+ VERBOSE_ARGS = "-v"
30
+ LIST_ARGS = "-ln"
31
+
32
+ # CppUTest verbose (-v) output. Test ids are 'Group.Name', matching
33
+ # the executable's own -ln enumeration format.
34
+ _RAN = re.compile(r"^(?:IGNORE_)?TEST\((\w+), (\w+)\)", re.M)
35
+ # Eclipse-style failure header: '<file>:<line>: error: Failure in
36
+ # TEST(<group>, <name>)'. When the failure is outside the test file,
37
+ # a second '<file>:<line>: error:' location line follows with the
38
+ # actual failure site; the tab-indented message lines come last.
39
+ _FAILED = re.compile(
40
+ r"^(.*):(\d+): error: Failure in TEST\((\w+), (\w+)\)$", re.M)
41
+ _LOCATION = re.compile(r"^(.*):(\d+): error:$")
42
+ _SUMMARY = re.compile(r"^(?:OK|Errors) \(.*\)", re.M)
43
+ _LIST_ID = re.compile(r"(\w+)\.(\w+)$")
44
+
45
+
46
+ def list_argv():
47
+ """Suite argv that enumerates tests in parse_list()'s format."""
48
+ return LIST_ARGS
49
+
50
+
51
+ def run_all_argv():
52
+ """Suite argv that runs every test in parse_run()'s format."""
53
+ return VERBOSE_ARGS
54
+
55
+
56
+ def run_one_argv(group, name):
57
+ """Suite argv that runs exactly one test in parse_run()'s
58
+ format ('-sg'/'-sn' are CppUTest's strict-match filters)."""
59
+ return f"{VERBOSE_ARGS} -sg {group} -sn {name}"
60
+
61
+
62
+ def parse_list(text):
63
+ """Parse '-ln' enumeration output: space-separated 'Group.Name'
64
+ tokens. Returns a list of TestId."""
65
+ text = text.replace("\r\n", "\n")
66
+ ids = []
67
+ for token in text.split():
68
+ match = _LIST_ID.fullmatch(token)
69
+ if not match:
70
+ raise ValueError(
71
+ f"not a CppUTest -ln test list:\n{text}")
72
+ ids.append(TestId(*match.groups()))
73
+ return ids
74
+
75
+
76
+ def _failures(text):
77
+ """Map 'Group.Name' -> (file, line, message) for each failure
78
+ block in verbose output."""
79
+ lines = text.splitlines()
80
+ failures = {}
81
+ for match in _FAILED.finditer(text):
82
+ file, line, group, name = match.groups()
83
+ rest = lines[text.count("\n", 0, match.start()) + 1:]
84
+ if rest and _LOCATION.match(rest[0]):
85
+ # Failure outside the test file: the second location
86
+ # line carries the actual failure site.
87
+ file, line = _LOCATION.match(rest[0]).groups()
88
+ rest = rest[1:]
89
+ message = []
90
+ for text_line in rest:
91
+ if not text_line:
92
+ break
93
+ message.append(text_line.lstrip("\t"))
94
+ failures[f"{group}.{name}"] = (file, int(line), "\n".join(message))
95
+ return failures
96
+
97
+
98
+ def parse_run(text):
99
+ """Parse verbose (-v) run output into a list of TestOutcome, in
100
+ output order. Raises ValueError when the output carries no
101
+ CppUTest summary line (the run died before finishing). DOS logs
102
+ arrive with CRLF line endings; both endings are accepted."""
103
+ text = text.replace("\r\n", "\n")
104
+ if not _SUMMARY.search(text):
105
+ raise ValueError(
106
+ f"no CppUTest summary line in suite output:\n{text}")
107
+ failures = _failures(text)
108
+ outcomes = []
109
+ for group, name in _RAN.findall(text):
110
+ file, line, message = failures.get(f"{group}.{name}", ("", 0, ""))
111
+ outcomes.append(TestOutcome(
112
+ group=group, name=name,
113
+ passed=f"{group}.{name}" not in failures,
114
+ file=file, line=line, message=message))
115
+ return outcomes
116
+
117
+
118
+ def parse(text):
119
+ """Normalize CppUTest verbose output: return a dict with "ran" and
120
+ "failed" (sets of 'Group.Name' ids) and "summary" (the OK/Errors
121
+ line)."""
122
+ outcomes = parse_run(text)
123
+ return {
124
+ "ran": {f"{o.group}.{o.name}" for o in outcomes},
125
+ "failed": {f"{o.group}.{o.name}" for o in outcomes
126
+ if not o.passed},
127
+ "summary": _SUMMARY.search(text).group(0),
128
+ }
testaferro/facade.py ADDED
@@ -0,0 +1,227 @@
1
+ # SPDX-FileCopyrightText: 2026 Paul Galbraith
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ """The pytest facade: surface a guest suite's tests as pytest items.
4
+
5
+ A consumer's test module hands over a reference to its suite
6
+ executable (public entry point: testaferro.guest_suite):
7
+
8
+ test_guest_case = testaferro.guest_suite(HERE / "SUITE.EXE")
9
+
10
+ The executable is interrogated to select the matching platform binding
11
+ (currently: DOS programs, run under QEMU); a provably unsupported
12
+ binary is rejected with a clear error. Alternatively a prebuilt
13
+ Backend may be passed in place of the path — the custom execution
14
+ escape hatch and test seam for this facade.
15
+
16
+ Each of the suite's tests becomes one pytest item, so pytest's own
17
+ selection (-k, node ids) drives what actually runs remotely. When the
18
+ whole suite is selected, the facade batches everything into a single
19
+ run_all(); a narrowed selection falls back to individual run_test()
20
+ calls. Remote failures are replayed with the
21
+ guest side's original file/line/message rather than a traceback into
22
+ this facade.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import importlib
28
+ import os
29
+
30
+ from . import binfmt
31
+ from .backend import TestId
32
+
33
+ # platform name -> binding module (a sibling of this one), imported only
34
+ # when dispatch selects it
35
+ _PLATFORM_BINDINGS = {"dos": "qemu"}
36
+
37
+
38
+ class ResultBroker:
39
+ """Lazily fetch outcomes for one suite, deciding between one
40
+ batched run_all() and per-test run_test() calls from how much of
41
+ the suite pytest actually selected. pytest-free on purpose."""
42
+
43
+ def __init__(self, backend, ids):
44
+ self._backend = backend
45
+ self._ids = list(ids)
46
+ self._batch = None
47
+ self._single = {}
48
+
49
+ def outcome(self, test_id, selected_ids):
50
+ if set(selected_ids) == set(self._ids):
51
+ if self._batch is None:
52
+ self._batch = {TestId(o.group, o.name): o
53
+ for o in self._backend.run_all()}
54
+ if test_id not in self._batch:
55
+ raise LookupError(
56
+ f"target did not run test {test_id} "
57
+ "(host and target test lists out of sync?)")
58
+ return self._batch[test_id]
59
+ if test_id not in self._single:
60
+ self._single[test_id] = self._backend.run_test(
61
+ test_id.group, test_id.name)
62
+ return self._single[test_id]
63
+
64
+
65
+ def guest_suite(target, framework=None, enumerator=None,
66
+ platform=None, machine=None, **machine_options):
67
+ """Return a pytest test function with one parameterized item per
68
+ test in the referenced suite. Assign it to a test_-prefixed
69
+ module attribute:
70
+
71
+ test_guest_case = testaferro.guest_suite(HERE / "SUITE.EXE")
72
+
73
+ `target` is a path to the suite executable (interrogated to pick
74
+ the guest backend) or a prebuilt Backend. The keyword options
75
+ apply to the path form only: `framework` overrides the framework
76
+ adapter, `enumerator` supplies a faster host-side source of the
77
+ test list, `platform` chooses an OS platform when the executable's
78
+ own format should not decide, and `machine` chooses a named test
79
+ machine declared with testaferro.config() or testaferro.ini
80
+ (searched upward from this call site). Any further keyword is
81
+ machine-specific and validated by the selected binding: today,
82
+ `boot_image=` or `machine_config=` for DOS.
83
+
84
+ Enumeration (backend.list_tests()) happens in its own session at
85
+ import/collection time. A second session starts lazily when the
86
+ first selected item runs and is cleaned up when pytest finishes.
87
+
88
+ The returned function is re-homed to the caller's file and call
89
+ line, so IDE per-item actions that key on source location (run
90
+ one item, jump to source) resolve to the consumer's module
91
+ rather than this facade.
92
+ """
93
+ import inspect
94
+
95
+ import pytest
96
+
97
+ frame = inspect.currentframe()
98
+ caller = None if frame is None else frame.f_back
99
+ call_site = (None if caller is None else
100
+ (caller.f_code.co_filename, caller.f_lineno))
101
+ del frame, caller
102
+
103
+ options = {name: value
104
+ for name, value in (("framework", framework),
105
+ ("enumerator", enumerator))
106
+ if value is not None}
107
+ options.update(machine_options)
108
+ if isinstance(target, (str, os.PathLike)):
109
+ search_from = (None if call_site is None
110
+ else os.path.dirname(call_site[0]))
111
+ backend = _dispatched_backend(
112
+ target, platform, machine, options,
113
+ search_from=search_from)
114
+ else:
115
+ given = sorted(options)
116
+ if platform is not None:
117
+ given.append("platform")
118
+ if machine is not None:
119
+ given.append("machine")
120
+ if given:
121
+ raise TypeError(
122
+ "keyword options apply only when passing an "
123
+ "executable path; a prebuilt Backend carries its own "
124
+ "configuration: " + ", ".join(given))
125
+ backend = target
126
+
127
+ try:
128
+ backend.start_session()
129
+ ids = list(backend.list_tests())
130
+ finally:
131
+ backend.stop_session()
132
+ broker = ResultBroker(backend, ids)
133
+ execution_session_started = False
134
+
135
+ def start_execution_session(config):
136
+ nonlocal execution_session_started
137
+ if execution_session_started:
138
+ return
139
+ try:
140
+ backend.start_session()
141
+ config.add_cleanup(backend.stop_session)
142
+ except BaseException:
143
+ backend.stop_session()
144
+ raise
145
+ execution_session_started = True
146
+
147
+ @pytest.mark.parametrize("guest_test", ids, ids=_item_id)
148
+ def run_guest_test(guest_test, request):
149
+ start_execution_session(request.config)
150
+ try:
151
+ outcome = broker.outcome(guest_test,
152
+ _selected_ids(request, broker))
153
+ except LookupError as error:
154
+ pytest.fail(str(error), pytrace=False)
155
+ if not outcome.passed:
156
+ where = (f"{outcome.file}:{outcome.line}: "
157
+ if outcome.file else "")
158
+ pytest.fail(f"guest test failed: {where}{outcome.message}",
159
+ pytrace=False)
160
+
161
+ if call_site is not None:
162
+ run_guest_test.__code__ = run_guest_test.__code__.replace(
163
+ co_filename=call_site[0], co_firstlineno=call_site[1])
164
+ run_guest_test._testaferro_broker = broker
165
+ return run_guest_test
166
+
167
+
168
+ def _dispatched_backend(target, platform, machine, options,
169
+ search_from=None):
170
+ """Build the suite backend for an executable path: select the
171
+ platform binding — from the caller's machine/platform choice or
172
+ the executable's own format — import it, and hand it its options."""
173
+ from . import machines
174
+
175
+ machines.load_config(search_from=search_from)
176
+ if platform is not None:
177
+ platform = str(platform).lower()
178
+ if platform not in _PLATFORM_BINDINGS:
179
+ raise ValueError(f"unsupported platform {platform!r}; "
180
+ "supported: "
181
+ + ", ".join(sorted(_PLATFORM_BINDINGS)))
182
+ fmt = binfmt.classify(target)
183
+ if fmt.platform is None and platform is None and machine is None:
184
+ raise ValueError(
185
+ f"{os.path.basename(os.fspath(target))} is "
186
+ f"{fmt.kind} executable; no supported platform can run it")
187
+ selected = machines.select(machine, platform, fmt.platform)
188
+ if selected is not None:
189
+ _, machine_config = selected
190
+ if "machine_config" in options:
191
+ raise TypeError(
192
+ "machine_config cannot be combined with a named machine")
193
+ selected_platform = machine_config.platform
194
+ options["machine_config"] = machine_config
195
+ else:
196
+ selected_platform = platform if platform is not None else fmt.platform
197
+ if selected_platform not in _PLATFORM_BINDINGS:
198
+ raise ValueError(f"unsupported platform {selected_platform!r}; "
199
+ "supported: "
200
+ + ", ".join(sorted(_PLATFORM_BINDINGS)))
201
+ binding = importlib.import_module(
202
+ "." + _PLATFORM_BINDINGS[selected_platform], __package__)
203
+ try:
204
+ return binding.suite_backend(target, **options)
205
+ except TypeError as error:
206
+ if "unexpected keyword argument" not in str(error):
207
+ raise
208
+ raise TypeError(f"{error} — options are machine-specific; "
209
+ f"the selected platform is "
210
+ f"{selected_platform!r}") from None
211
+
212
+
213
+ def _item_id(test_id):
214
+ """The bracketed id for one guest test item. A dash join, not
215
+ str(TestId): a dot inside a parametrize id breaks IDE
216
+ tree→target mapping (dots are hierarchy separators there),
217
+ turning run-this-item into run-the-whole-file."""
218
+ return f"{test_id.group}-{test_id.name}"
219
+
220
+
221
+ def _selected_ids(request, broker):
222
+ """The suite's test ids that survived pytest's selection —
223
+ exactly the items pytest is going to run this session."""
224
+ return [item.callspec.params["guest_test"]
225
+ for item in request.session.items
226
+ if getattr(item.function, "_testaferro_broker", None)
227
+ is broker]