fix(tests): restore sys.modules after a test installs a stand-in module (#660) - #662
Conversation
NEEDS CHANGESCross-review by an independent agent on a different model (Sonnet), briefed to falsify rather than confirm, per the house rule that an agent-raised PR gets a cross-review on a model other than the authoring one (this PR was authored by Opus). It ran read-only, in its own The required change
That class's Measured by subclassing Independently arrived at the same finding before the review landed (measured The reviewer's proposed fix is better than the one I had planned and is what will be applied: make On that last point the reviewer also talked me out of a change I was considering: a It classes this as a performance/documentation defect, not a correctness one: no test result changes and nothing newly fails. On the fix mechanism itself: no counter-example foundThe central question put to it was whether this is a heal-by-neighbour fix in disguise. It tried orderings this PR does not:
Every ordering exited 0. No counter-example. It also verified the two things that would have made the fix hollow:
Numbers reproduced: Two findings it raised that are not blocking, both accepted
Where it disagreed with its own brief, and one number it could not reproduceReported honestly rather than folded away: it measured the wide cross-tier selection as 150 → 159 passed where this PR says 156 → 165. The delta is +9 either way and subtests match exactly at 722, so the mechanism agrees; the absolute counts differ, most likely an optional-dependency skip/collection difference between environments. It could not pin the cause down and says so. It also did not reproduce the supplementary One process note, in fairness to it: I switched the shared worktree to another branch shortly after dispatching it, which could have invalidated its measurements. It had already detected the mismatch itself and built its own verified worktree, so nothing in its report is an artefact of my error — and when I messaged it to redirect, it declined and pushed back on one of my two technical claims, correctly. Scope verified: one commit, The docstring and |
9abc1f2 to
08dc75c
Compare
NEEDS CHANGES addressed — pushed as
|
| tree | a_enter |
a_exit |
b_warm |
c_warm |
|---|---|---|---|---|
unmodified main |
False | True | True | True |
| this branch as first pushed | False | True | False | False |
| this branch, amended | True | True | True | True |
The middle row is the regression, reproduced independently of the reviewer. The bottom row restores main's behaviour and slightly improves on it, since a_enter is warm too.
Two measurements agreeing on the cost, by different routes: the reviewer counted 83 methods across the 9 files that subclass it at 0.6-0.85s per re-import; I counted 92 test methods across 28 classes in tests/integration/ by AST and measured import pcapkit at 0.707s (0.736s process total less 0.029s interpreter startup, median of 5). Either way ~45-58s, now zero.
2. The pytest_sessionstart docstring
Rewritten, and it now says the thing that is actually true rather than a softened version of the wrong thing. It no longer claims warming helps a class that purges per class, and explains why the obvious reading fails: such a class purges after the hook and before the first snapshot, so warming the table earlier cannot affect its snapshot at all. It distinguishes the two shapes — a class that loads something straight after its own purge (snapshot populated, nothing lost) from one that defers the import to its methods (every method re-imports) — names EndToEndTestCase as the sole instance of the latter, records the cost, and tells a future per-class purger to re-import.
3. restore_modules — the identity-versus-mutation caveat
Accepted and documented. The docstring now says it is an inverse over the bindings and not over module contents: a test that mutates an already-imported module in place rebinds nothing, so there is nothing for this to undo, and isolating against that needs a purge so the next import rebuilds from source — which is what purge_modules' callers are doing. #660 was a rebinding, which is why this is the right shape for it.
Not changed, with reasons
- The
__spec__ is Noneheuristic stays rejected. I had been considering it; the reviewer's argument against is better than my reason for and is now on the record in the previous comment. The unconditional full restore is unchanged. _method_names'sorted(...)[0]is left as is. With zero test methods onProtoChainTestsit raisesIndexError— loud, immediate, and in a test whose entire purpose is that class. Guarding it would trade a loud failure for a skip.tests/cli/test_main.pystill leaks at the source level, deliberately, as the witness that the structural guard covers a file with notests._supportdependency.
Re-verification after the amendment
Exit codes read from files.
- the three-file order from the issue:
EXIT=0,17 passed, 432 subtestsin 0.13s test_module_isolation.py+test_support_helpers.py+test_protochain.py+tests/const+test_module_loading.py:EXIT=0, 48 passed, 249 subtests, 208 MB peak RSS- the subclass probe above: 3 passed, warm on every method
On the reviewer's one unreproduced number — it measured the wide cross-tier selection as 150 → 159 where this PR says 156 → 165, with the delta (+9) and the subtest count (722) matching exactly both times. That is an absolute collection count differing between environments, not a disagreement about the change, and it is recorded rather than reconciled.
…le (#660) Twelve tests in ``tests/project/`` failed with ``TypeError: type 'ProtocolBase' is not subscriptable`` depending only on what had run before them: the same three files passed in the reverse order. ``tests/_support.py``'s fake-module helpers isolated a test by purging ``sys.modules`` on entry and never restoring on exit, so a stand-in ``ProtocolBase`` that is not ``Generic`` stayed bound after the test that installed it, and every later ``class X(Protocol[...])`` raised at ``pcapkit/protocols/misc/pcap/frame.py:59``. * ``tests/_support.py`` gains ``snapshot_modules``/``restore_modules`` and ``isolate_modules``, which purge on entry and restore on teardown via ``addCleanup``. ``purge_modules`` keeps its purge-only behaviour, which is correct for the ~120 callers that only re-import the real package. * The stand-in installers now require the running test and refuse to bind anything without active isolation, so the same mistake is a ``RuntimeError`` at the call rather than failures in another directory. * ``tests/conftest.py`` restores the ``pcapkit`` region after every test, which covers files that roll their own purge and never import ``tests._support`` -- ``tests/cli/test_main.py`` is one, and is left unchanged as the witness. ``pytest_sessionstart`` imports the package once so that restore is a no-op for tests that do not purge: ``tests/project/`` is 0.35s warm against 9.34s cold and 1.59s unguarded. * Five leaks fixed at the call site: ``tests/corekit/test_protochain.py``, ``tests/interface/test_core.py``, ``tests/protocols/transport/`` ``test_transport_unit.py``, ``tests/utilities/test_decorators.py``, and ``tests/integration/test_module_loading.py`` -- the last a second, differently failing leak not in the issue. * ``EndToEndTestCase.setUpClass`` re-imports after purging. Without it the per-test restore snapshots an empty table, costing that tier a 0.7s re-import on each of its 92 test methods instead of one per class. Deliberately not fixed by moving the file's sort position or leaning on a neighbour's purge: accidental healing by a neighbour is why this stayed hidden. Nine new tests. The three-file order from the issue: exit 1, 12 failed 5 passed before; exit 0, 17 passed after. Cross-tier selection 156 -> 165 passed, both exit 0.
08dc75c to
6e7c998
Compare
GOOD TO GO — cross-review, second passThe same Sonnet reviewer re-ran its checks against the amended commit in a fresh worktree it built itself, and returned GOOD TO GO. Its earlier NEEDS CHANGES item is closed. What it re-verified independently, with its own probes rather than mine:
It also had no objection to the two things left as they were — One disagreement, and I have kept my change over its adviceIts verdict was given on It wrote that "every concrete subclass across So the line is reachable without the dependencies, through those two. Forced the failure directly, by patching Its second ground — that failing loudly is more informative — is a fair argument, and it verified there is no partial The two classes are named in the docstring, so the next reader does not have to rediscover which subclasses make the branch reachable. Verified after guarding: three-file order Final state: |
…ce (#674) `tests/_support.py`'s `load_module` writes to `sys.modules` twice over -- the module it was asked for, and a bare stub package for each parent of that module's dotted name, via `ensure_package` -- and took neither back off again. Five test modules therefore left `pcapkit`, `pcapkit.corekit` and `pcapkit.utilities` bound to stubs carrying nothing but a `__path__`, so the next test to walk `pcapkit.__all__` saw a library that declared no exports. * `restore_modules_after` snapshots the `pcapkit` region and puts it back on teardown via `addCleanup`, exactly -- including absence, which is both the direction the defect was and the one a `dict.update` of a snapshot gets wrong. Registered once per test however many times it is called, so the snapshot kept is the earliest one, taken before any load wrote anything. * `load_module` and `bootstrap_core_modules` arrange that for themselves, finding the running `TestCase` from the calling frames as `sample_path` already does. No call site changes, so the restore covers the five leaking modules and every future caller rather than only the ones someone remembers to convert. * The walk takes the nearest *running* test, not merely the nearest local named `self`. A free function with a parameter of that name presents the same frame, and one handed a finished `TestCase` would collect a cleanup nobody ever runs -- measured at a silent ten-name leak before `_is_running_test` was consulted. * `ensure_package` refuses to bind a stub for a test that has arranged nothing, and the loaders refuse when no running test can be found. Both name the fix. * `purge_modules` stays purge-only; its docstring now says why that is correct rather than the same gap seen from the other side. * `LoadedModulesDoNotOutliveTheirTestTests` and `ArrangedRestoreTests` pin it, running the five real polluting cases through `unittest.TestResult`. Under pytest the #662 guard in `tests/conftest.py` repairs the table either way, which is why the two-file pytest repro quoted in the issue stopped failing while the defect itself was still there. `python -m unittest tests.corekit.test_multidict tests.project.test_public_api` goes from `FAILED (failures=3)` to `OK`, and the same for `test_io`, `test_module` and `test_exceptions_warnings`. The new tests fail 10 of 32 against a helper with the restore neutered, and the frame-walk test fails on its own against a name-only walk. pcapkit coverage is unchanged byte-for-byte, no pcapkit line having changed; the frame walk and `TestCase._outcome` are verified on CPython 3.10 through 3.14. Fixes #674
…ce (#674) `tests/_support.py`'s `load_module` writes to `sys.modules` twice over -- the module it was asked for, and a bare stub package for each parent of that module's dotted name, via `ensure_package` -- and took neither back off again. Five test modules therefore left `pcapkit`, `pcapkit.corekit` and `pcapkit.utilities` bound to stubs carrying nothing but a `__path__`, so the next test to walk `pcapkit.__all__` saw a library that declared no exports. * `restore_modules_after` snapshots the `pcapkit` region and puts it back on teardown via `addCleanup`, exactly -- including absence, which is both the direction the defect was and the one a `dict.update` of a snapshot gets wrong. Registered once per test however many times it is called, so the snapshot kept is the earliest one, taken before any load wrote anything. * `load_module` and `bootstrap_core_modules` arrange that for themselves, finding the running `TestCase` from the calling frames as `sample_path` already does. No call site changes, so the restore covers the five leaking modules and every future caller rather than only the ones someone remembers to convert. * The walk takes the nearest *running* test, not merely the nearest local named `self`. A free function with a parameter of that name presents the same frame, and one handed a finished `TestCase` would collect a cleanup nobody ever runs -- measured at a silent ten-name leak before `_is_running_test` was consulted. * `ensure_package` refuses to bind a stub for a test that has arranged nothing, and the loaders refuse when no running test can be found. Both name the fix. * `purge_modules` stays purge-only; its docstring now says why that is correct rather than the same gap seen from the other side. * `LoadedModulesDoNotOutliveTheirTestTests` and `ArrangedRestoreTests` pin it, running the five real polluting cases through `unittest.TestResult`. Under pytest the #662 guard in `tests/conftest.py` repairs the table either way, which is why the two-file pytest repro quoted in the issue stopped failing while the defect itself was still there. `python -m unittest tests.corekit.test_multidict tests.project.test_public_api` goes from `FAILED (failures=3)` to `OK`, and the same for `test_io`, `test_module` and `test_exceptions_warnings`. The new tests fail 11 of 33 against a helper with the restore neutered, and the frame-walk test fails on its own against a name-only walk. pcapkit coverage is unchanged byte-for-byte, no pcapkit line having changed; the frame walk and `TestCase._outcome` are verified on CPython 3.10 through 3.14. Fixes #674
…ce (#674) (#686) `tests/_support.py`'s `load_module` writes to `sys.modules` twice over -- the module it was asked for, and a bare stub package for each parent of that module's dotted name, via `ensure_package` -- and took neither back off again. Five test modules therefore left `pcapkit`, `pcapkit.corekit` and `pcapkit.utilities` bound to stubs carrying nothing but a `__path__`, so the next test to walk `pcapkit.__all__` saw a library that declared no exports. * `restore_modules_after` snapshots the `pcapkit` region and puts it back on teardown via `addCleanup`, exactly -- including absence, which is both the direction the defect was and the one a `dict.update` of a snapshot gets wrong. Registered once per test however many times it is called, so the snapshot kept is the earliest one, taken before any load wrote anything. * `load_module` and `bootstrap_core_modules` arrange that for themselves, finding the running `TestCase` from the calling frames as `sample_path` already does. No call site changes, so the restore covers the five leaking modules and every future caller rather than only the ones someone remembers to convert. * The walk takes the nearest *running* test, not merely the nearest local named `self`. A free function with a parameter of that name presents the same frame, and one handed a finished `TestCase` would collect a cleanup nobody ever runs -- measured at a silent ten-name leak before `_is_running_test` was consulted. * `ensure_package` refuses to bind a stub for a test that has arranged nothing, and the loaders refuse when no running test can be found. Both name the fix. * `purge_modules` stays purge-only; its docstring now says why that is correct rather than the same gap seen from the other side. * `LoadedModulesDoNotOutliveTheirTestTests` and `ArrangedRestoreTests` pin it, running the five real polluting cases through `unittest.TestResult`. Under pytest the #662 guard in `tests/conftest.py` repairs the table either way, which is why the two-file pytest repro quoted in the issue stopped failing while the defect itself was still there. `python -m unittest tests.corekit.test_multidict tests.project.test_public_api` goes from `FAILED (failures=3)` to `OK`, and the same for `test_io`, `test_module` and `test_exceptions_warnings`. The new tests fail 11 of 33 against a helper with the restore neutered, and the frame-walk test fails on its own against a name-only walk. pcapkit coverage is unchanged byte-for-byte, no pcapkit line having changed; the frame walk and `TestCase._outcome` are verified on CPython 3.10 through 3.14. Fixes #674
Fixes #660
What was wrong
Twelve tests in
tests/project/failed withTypeError: type 'ProtocolBase' is not subscriptabledepending only on what had run before them. The same three files passed in the reverse order.tests/_support.py's fake-module helpers isolated a test by purgingsys.moduleson entry and never restoring on exit.tests/corekit/test_protochain.pyinstalled a stand-inProtocolBasethat is notGenericand finished without removing it, so every laterclass X(Protocol[...])raised atpcapkit/protocols/misc/pcap/frame.py:59.The design choice, and why the alternatives were rejected
The invariant is a test leaves the
pcapkitregion ofsys.modulesexactly as it found it. Three ways to get there, and this PR does the first two:snapshot_modules/restore_modulesplusisolate_modules(self), which purges on entry and restores on teardown throughaddCleanup.addCleanuprather thantearDownso it also runs whensetUpraises half-way through installing the stand-ins.tests/conftest.pyrestores the region after every test. This is load-bearing rather than belt-and-braces: three separate files leak this way, and one of them —tests/cli/test_main.py— rolls its own purge loop and never importstests._support, so nothing in that module could have fixed it. It is left unchanged on purpose, as the standing witness that the guard covers a file which has not opted into anything. The installers additionally now require the running test and refuse to bind a stand-in without active isolation, so the same mistake is aRuntimeErrorat the call site rather than failures in another directory.Rejected: adding the missing
tearDown. It fixes the files that have it and nothing else. The next file written without one reintroduces the identical order-dependent failure — that is unlikely, not impossible.tests/cli/test_main.pyshows the pattern already recurred independently.Rejected: removing the fake-install idiom entirely. Not achievable.
load_module/bootstrap_core_modulesmust bind intosys.modules, because that is how the module under test resolves its ownfrom pcapkit... import ...lines.Rejected, and it would have been the wrong fix: moving
test_protochain.pyso it sorts elsewhere, or relying on a neighbouring test's purge to heal the state. Accidental healing by a neighbour is exactly why this stayed hidden — the file sorts last insidetests/corekit/, and the full CI selection passes only because unrelated directories sort between it andtests/project/. A fix of that shape re-hides the defect and the next file added between the two re-exposes it.purge_moduleskeeps its purge-only behaviour, documented as such: it is correct for the ~120 callers that purge and then import only the real package.tests/integration/— it does share the defectFlagged as unknown in the issue. Checked, and it leaks, with a different failure mode.
tests/integration/test_module_loading.pycallspurge_modules+bootstrap_core_modules()inside the test body with no cleanup at all, leaving a stubpcapkitcarrying only__path__:It is healed by accident too —
EndToEndTestCase.setUpClasspurges — so the tier passes as a whole. Fixed at the call site here.Evidence
Exit codes read from files,
.venv/bin/python3.14.7, pytest 9.1.1.EXIT=112 failed, 5 passedEXIT=017 passedEXIT=017 passedEXIT=017 passedtest_module_loading.pythentest_public_api.pyEXIT=13 failed, 8 passedEXIT=011 passedtests/cli/test_main.pythentest_public_api.pyEXIT=110 failed, 6 passedEXIT=016 passedWide cross-tier selection (
tests/test_support_helpers.py tests/project tests/cli tests/const tests/dumpkit+ the two polluters), bothEXIT=0: 156 passed / 722 subtests before, 165 passed / 722 subtests after — the +9 are the new tests.tests/corekit tests/utilities tests/interfacetogether: 292 passed,EXIT=0, 315 MB peak RSS.Nine new tests, all of which fail on
main:tests/project/test_module_isolation.pyruns pytest in a subprocess over each selection above. Cross-file ordering is not observable from inside a single test, so this is the only way to state the invariant.tests/test_support_helpers.py::StandInsDoNotOutliveTheirTestTestsruns the real polluting test case throughunittest.TestResultand diffssys.modulesacross it. Deliberately not via pytest: the conftest fixture would clean up either way and the assertion would pass whether or not the test case itself had been fixed.tests/test_support_helpers.py::SnapshotRestoreTestscovers the restore as an exact inverse over all three cases — a name added, a name dropped, a name rebound. The rebind is the one twelve tests/project/ tests fail withTypeError: type 'ProtocolBase' is not subscriptabledepending on what ran first: the fake-module helpers purge on entry and never restore on exit #660 turned on.Performance
The guard makes each test that does not purge for itself re-import the package, unless the table starts warm — so
pytest_sessionstartimports it once. Over the 96 tests oftests/project/: 1.59s unguarded, 9.34s guarded from a cold table, 0.35s guarded from a warm one. Measured, and the numbers are in the docstring.Warming does not help a class that purges in
setUpClass, and that is worth being explicit about because the obvious reading is wrong: such a class purges after the hook and before the first snapshot, so its snapshot is whatever it left behind. Where the class loads something straight after its own purge the snapshot is populated anyway; where it purges and defers the import to its test methods, every method re-imports.Exactly one class in the suite did the latter —
tests/integration/_helpers.py::EndToEndTestCase— so it now re-imports in its ownsetUpClass. Without that, the tier's 92 test methods across 28 classes each paid a measured 0.707s re-import instead of one per class, some 45s. Probed with a throwaway subclass whose second and third methods report whetherpcapkitis still insys.modules:mainThe re-import is wrapped in
except Exceptionbecause it is an optimisation and must not change a failure mode. Three of the tier's 28 classes carry no@skipUnless(HAS_RUNTIME, ...), two of which subclassEndToEndTestCase, so on a checkout without the runtime dependencies an unguardedimportwould raise out ofsetUpClassand error those classes where before their individual tests failed. Verified by patchingimportlib.import_moduleto raise: unguarded propagates, guarded survives and the table simply stays cold.BaseExceptionis not caught.Coverage
No
pcapkitline changes, so[tool.coverage.run] source = ["pcapkit"]cannot move. The test-side measure is the count: +9 tests, and subtest totals unchanged at 722 in the cross-tier selection and 432 in the three-file selection, i.e. nothing was narrowed to make this pass.Notes
tests/cli/test_main.pyis knowingly left leaking at the source level, covered by the conftest guard. Fixing it inline would remove the only witness that the structural half works; it has notests._supportdependency to hangisolate_modulesoff without restructuring the file.tests/project/test_setup.pybinds a fakesetuptools, andtests/cli/test_main.pypopsemoji. Neither is under apcapkitprefix, so neither is covered by the guard's default prefixes, and neither has been observed to break anything.