|
9 | 9 | Each example runs in a fresh interpreter rather than in this one. An example that |
10 | 10 | only works because a test already imported something is not an example a reader |
11 | 11 | can paste, and running it here in-process would hide that. |
| 12 | +
|
| 13 | +An example that cannot run at all here is reported as skipped rather than as |
| 14 | +broken, and only when the member itself says why. Two members model a part that |
| 15 | +runs a program their repository is not allowed to carry, so on a machine without |
| 16 | +one every example that builds a part refuses. That refusal is the package working |
| 17 | +correctly, and counting it as a broken example would make a bare checkout look |
| 18 | +like a defect while hiding real ones behind it. |
| 19 | +
|
| 20 | +What decides is the member's own `why_not`, which is the same sentence its doctor |
| 21 | +prints. A member that publishes none skips nothing, and on a machine that has the |
| 22 | +files nothing is skipped either, so the check keeps its teeth exactly where it |
| 23 | +had them. |
12 | 24 | """ |
13 | 25 |
|
14 | 26 | from __future__ import annotations |
15 | 27 |
|
| 28 | +import importlib |
16 | 29 | import re |
17 | 30 | import subprocess |
18 | 31 | import sys |
19 | 32 | import unittest |
20 | 33 | from pathlib import Path |
| 34 | +from types import ModuleType |
21 | 35 |
|
22 | 36 | ROOT = Path(__file__).resolve().parent.parent |
23 | 37 |
|
24 | 38 | README = ROOT / "README.md" |
25 | 39 |
|
| 40 | + |
| 41 | +def packages() -> list[str]: |
| 42 | + """The importable package in this repository, which is the member itself. |
| 43 | +
|
| 44 | + The conformance directory is one too and is not the member, so it is left |
| 45 | + out by name rather than by position. |
| 46 | + """ |
| 47 | + return sorted( |
| 48 | + found.name |
| 49 | + for found in ROOT.iterdir() |
| 50 | + if (found / "__init__.py").is_file() and found.name != "conformance" |
| 51 | + ) |
| 52 | + |
| 53 | + |
| 54 | +def cannot_run_here(named: list[str] | None = None) -> str | None: |
| 55 | + """Why this member cannot build its part on this machine, or nothing. |
| 56 | +
|
| 57 | + Read off the package rather than guessed from a traceback, so the sentence |
| 58 | + an example is excused by is the sentence the member itself publishes. |
| 59 | +
|
| 60 | + The names are a parameter so this can be driven against a member that |
| 61 | + publishes a reason from a member that does not, and the other way round. |
| 62 | + Nine of the sixteen publish nothing, and a branch only two of them reach is |
| 63 | + a branch nobody has seen work. |
| 64 | + """ |
| 65 | + if str(ROOT) not in sys.path: |
| 66 | + sys.path.insert(0, str(ROOT)) |
| 67 | + for name in packages() if named is None else named: |
| 68 | + asked = getattr(importlib.import_module(name), "why_not", None) |
| 69 | + if callable(asked): |
| 70 | + answer = asked() |
| 71 | + return str(answer) if answer else None |
| 72 | + return None |
| 73 | + |
| 74 | + |
| 75 | +def excused(failure: str, reason: str | None = None) -> bool: |
| 76 | + """Whether that failure is the member saying it has no file to run. |
| 77 | +
|
| 78 | + Matched on a run of the member's own sentence rather than on an exception |
| 79 | + name, because the name differs per member and the sentence is the thing the |
| 80 | + member publishes for exactly this purpose. |
| 81 | + """ |
| 82 | + said = cannot_run_here() if reason is None else reason |
| 83 | + return bool(said) and str(said)[:40] in failure |
| 84 | + |
| 85 | + |
26 | 86 | BLOCK = re.compile(r"^```(\w*)\n(.*?)^```$", re.M | re.S) |
27 | 87 |
|
28 | 88 |
|
@@ -61,29 +121,38 @@ def ran(source: str) -> subprocess.CompletedProcess[str]: |
61 | 121 | ) |
62 | 122 |
|
63 | 123 |
|
64 | | -def broken(found: list[tuple[str, str | None]]) -> list[str]: |
| 124 | +def broken(found: list[tuple[str, str | None]], reason: str | None = None) -> list[str]: |
65 | 125 | """The last line of the traceback of every example that will not run. |
66 | 126 |
|
67 | 127 | A process can exit non-zero and print nothing, so the reason falls back to |
68 | 128 | the exit code rather than indexing an empty list. A checker that raises |
69 | 129 | while collecting a fault reports neither that fault nor any after it. |
70 | 130 | """ |
71 | 131 | failed = [] |
| 132 | + reason = cannot_run_here() if reason is None else reason |
72 | 133 | for source, _ in found: |
73 | 134 | finished = ran(source) |
74 | | - if finished.returncode != 0: |
75 | | - said = finished.stderr.strip().splitlines() |
76 | | - failed.append(said[-1] if said else f"exited {finished.returncode} in silence") |
| 135 | + if finished.returncode == 0: |
| 136 | + continue |
| 137 | + if excused(finished.stderr, reason): |
| 138 | + continue |
| 139 | + said = finished.stderr.strip().splitlines() |
| 140 | + failed.append(said[-1] if said else f"exited {finished.returncode} in silence") |
77 | 141 | return failed |
78 | 142 |
|
79 | 143 |
|
80 | | -def mismatched(found: list[tuple[str, str | None]]) -> list[tuple[str, str]]: |
| 144 | +def mismatched( |
| 145 | + found: list[tuple[str, str | None]], reason: str | None = None |
| 146 | +) -> list[tuple[str, str]]: |
81 | 147 | """What the readme claims each example prints, beside what it printed.""" |
82 | 148 | wrong = [] |
| 149 | + reason = cannot_run_here() if reason is None else reason |
83 | 150 | for source, expected in found: |
84 | 151 | if expected is None: |
85 | 152 | continue |
86 | 153 | finished = ran(source) |
| 154 | + if finished.returncode != 0 and excused(finished.stderr, reason): |
| 155 | + continue |
87 | 156 | if finished.stdout != expected: |
88 | 157 | wrong.append((expected.strip(), finished.stdout.strip())) |
89 | 158 | return wrong |
@@ -137,6 +206,72 @@ def test_and_a_later_example_is_still_checked_after_an_earlier_one_failed(self) |
137 | 206 |
|
138 | 207 | self.assertEqual(mismatched(examples(readme)), [("2", "1")]) |
139 | 208 |
|
| 209 | + def test_a_member_that_can_run_everything_excuses_nothing(self) -> None: |
| 210 | + """The teeth stay where they were on every member that ships its own part.""" |
| 211 | + self.assertFalse(excused("anything at all", None if cannot_run_here() else "x" * 60)) |
| 212 | + |
| 213 | + def test_a_failure_the_member_says_it_expects_is_excused(self) -> None: |
| 214 | + reason = "no firmware image was found: this backend runs the part's own microcode" |
| 215 | + |
| 216 | + self.assertTrue(excused(f"Traceback\nNoFirmware: {reason}", reason)) |
| 217 | + |
| 218 | + def test_and_any_other_failure_is_not(self) -> None: |
| 219 | + """Driven against the shape that would otherwise slip through.""" |
| 220 | + reason = "no firmware image was found: this backend runs the part's own microcode" |
| 221 | + |
| 222 | + self.assertFalse(excused("Traceback\nZeroDivisionError: division by zero", reason)) |
| 223 | + |
| 224 | + def test_and_a_member_that_publishes_no_reason_excuses_nothing(self) -> None: |
| 225 | + self.assertFalse(excused("Traceback\nNoFirmware: anything", "")) |
| 226 | + |
| 227 | + def test_an_example_that_only_this_machine_can_run_is_reported_as_broken(self) -> None: |
| 228 | + """So the excuse cannot be claimed by an example that simply does not work.""" |
| 229 | + readme = "```python\nraise ValueError('nope')\n```\n" |
| 230 | + |
| 231 | + self.assertEqual(broken(examples(readme)), ["ValueError: nope"]) |
| 232 | + |
| 233 | + def test_an_example_the_member_says_it_cannot_run_is_not_reported_as_broken(self) -> None: |
| 234 | + readme = "```python\nraise SystemExit('no image is here')\n```\n" |
| 235 | + |
| 236 | + self.assertEqual(broken(examples(readme), "no image is here"), []) |
| 237 | + |
| 238 | + def test_and_its_stated_output_is_not_compared_either(self) -> None: |
| 239 | + """An example that never ran produced no output to compare.""" |
| 240 | + readme = "```python\nraise SystemExit('no image is here')\n```\n\n```\n7\n```\n" |
| 241 | + |
| 242 | + self.assertEqual(mismatched(examples(readme), "no image is here"), []) |
| 243 | + |
| 244 | + def test_a_member_that_publishes_a_reason_is_read(self) -> None: |
| 245 | + """Driven against a stand-in, because nine of the sixteen publish none.""" |
| 246 | + speaking = ModuleType("speaking") |
| 247 | + speaking.why_not = lambda: "no image is here" # type: ignore[attr-defined] |
| 248 | + sys.modules["speaking"] = speaking |
| 249 | + try: |
| 250 | + self.assertEqual(cannot_run_here(["speaking"]), "no image is here") |
| 251 | + finally: |
| 252 | + del sys.modules["speaking"] |
| 253 | + |
| 254 | + def test_and_one_that_publishes_nothing_to_say_says_nothing(self) -> None: |
| 255 | + quiet = ModuleType("quiet") |
| 256 | + quiet.why_not = lambda: None # type: ignore[attr-defined] |
| 257 | + sys.modules["quiet"] = quiet |
| 258 | + try: |
| 259 | + self.assertIsNone(cannot_run_here(["quiet"])) |
| 260 | + finally: |
| 261 | + del sys.modules["quiet"] |
| 262 | + |
| 263 | + def test_and_one_that_publishes_no_such_call_is_passed_over(self) -> None: |
| 264 | + silent = ModuleType("silent") |
| 265 | + sys.modules["silent"] = silent |
| 266 | + try: |
| 267 | + self.assertIsNone(cannot_run_here(["silent"])) |
| 268 | + finally: |
| 269 | + del sys.modules["silent"] |
| 270 | + |
| 271 | + def test_the_member_this_repository_holds_is_found_by_name(self) -> None: |
| 272 | + """So the sweep cannot start reading the conformance directory instead.""" |
| 273 | + self.assertNotIn("conformance", packages()) |
| 274 | + |
140 | 275 | def test_an_example_with_no_stated_output_is_only_run(self) -> None: |
141 | 276 | readme = "```python\nprint(1)\n```\n" |
142 | 277 |
|
|
0 commit comments