fix(tests): put sys.modules back after a test loads modules from source (#674) - #686
Conversation
fd69571 to
6f90a50
Compare
…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
6f90a50 to
a3d3754
Compare
|
GOOD TO GO Cross-review by an independent agent on a different model (Claude Sonnet) from the Per-claim verdicts
What it found, and what changed1. The frame walk took the nearest local named Fixed in The reviewer also noted, fairly, that the 2. $ python -m unittest tests.cli.test_main tests.project.test_public_api
FAILED (errors=10) # ImportError: cannot import name 'show_flag_values'
exit 1 # read from a fileSame shape as #674, different mechanism, and it wants its own change rather than being 3. The On the description's own claimsThe reviewer re-derived every specific number independently and found no overstatement It also flagged that "no Two things left open, stated plainly
|
`docs/source/` carried 1,022 autodoc directives whose target was a `_`-prefixed name. Most are earned -- each protocol class's registry `list-table` cross- references its `_read_*`/`_make_*` handlers by `:meth:` role, so those pages are reachable from the public API. 164 were not, and a directive for a private name nothing points at renders a page no reader can reach, while inviting the reverse error of treating the name as supported *because* it is documented. * The 164 removed span 33 pages. The bulk are the `_none` / `_unknown` / `_unassigned` dispatch fallbacks, which the registry tables deliberately omit and which are reached only through `getattr(self, meth_name, self._read_opt_none)` -- a code-level default, no more a cross-reference than a signature default is. * 590 private-target directives stay, each with a rendered reference naming it. Nine stay on the strength of a `:show-inheritance:` `Bases:` line from a documented subclass, which is a real rendered link into the private page: `_OPT_Option` and its five PCAP-NG siblings, plus `_IPField`, `_IPInterfaceField` and `_TextField`. * Dunders are out of scope, and `CONTRIBUTING.md` now records why. A `__dunder__` is reached through public syntax rather than by name, and the `__proto__` / `__option__` / `__schema__` family is the documented extension contract that `register_*` writes to. The one exception -- a dunder under a private class that is itself going -- did not arise: `_EnumRegistry` is named by five rendered docstrings, so it and its `__missing__` both stay. * `3rdparty.rst` loses an `Internal Definitions` heading whose entire body went with `_NamedStream`, `_get_decoder` and `_decode`. * `hip.rst` holds 48 further qualifying directives, left alone because #672/#679 own that page. They are listed in the pull request for follow-up. The operative test is whether Sphinx renders the reference. A role in a plain `#` comment, or one sitting on a symbol that has no directive of its own -- a `#:` comment on a constant with no `autodata`, an undocumented private helper's docstring -- is dead prose and was not counted. Applied to a fixed point, so a private member whose only reference lived in another private member that is also going was removed with it. No `pcapkit/` line changed, so there is no changelog entry, following #686. `sphinx-build -b html` exits 0 and goes from 55 warnings to 54, with none new -- the one that disappeared is the forward-reference warning for the removed `PyPCAPFile._decode`. No cross-reference was left dangling. Fixes #684
…enet (#684) The API reference should keep usage and extensibility clear while hiding the recipe. Measured against that, `docs/source/` was wrong in both directions: it documented a handful of module-level privates on nobody's surface, and it omitted 39 class private attributes that are squarely contract -- several of them the only undocumented member of a group whose siblings all have directives. * Removed 7 directives, all module-level privates: `esp._resolve`, `esp._CRYPTO`, `ngap._convert`, `ngap._revert`, `ngap._PYCRATE`, `ngap._PDU_LOCK` and the private stream adapter `pypcapfile._NamedStream`. A private helper, a lazily imported backend flag, an internal lock and a wrapper nobody constructs or subclasses. `_NamedStream`'s two nested members go with it, which is correct -- a member of a private class is reachable only through that class. * Added 39 `autoattribute` directives for class private attributes that carry contract. The clearest are the ones that were alone in being left out: `Extractor._flag_f`, the only one of ten `_flag_*` without a directive; `PCAP_CT._backend` and `PyPCAP._backend`, whose every sibling on the same class is documented; and the built-in `PCAP` and `PCAPNG` engines, which carried none of the private state block that all six third-party engines carry. The rest back a documented property, or are read and written directly by collaborators -- `Extractor._vfunc`, `_fext` and `EngineBase._extractor` are what `ext.rst` already teaches engine authors to use by name. * Documented the const enums' `_missing_` fallback once, on the `pcapkit.const` landing page, rather than on each of the 128 classes that implement it. Looking a registry value up by an unassigned-but-in-range number mints a member instead of raising, which is deliberate per #647 and is the extensibility behaviour of the whole package; `conf.py` already names `_missing_` in `exclude-members`, so per-class directives would be fighting the project's own configuration to say 128 times what one section says better. `registry.rst` gains a sentence pointing at it, since every `register_*` there takes a code the enum must already resolve. * `CONTRIBUTING.md` records the tenet: what counts as contract, that per-option `_read_*` / `_make_*` pairs publish the data format and stay, that most class private attributes stay, that anything abstract or implemented across subclasses must be documented, and that the recipe to hide is chiefly module-level privates. Deliberately not changed: the `_read_*` / `_make_*` families, which outline the constructor contract and the data format for each option, parameter, chunk and cause; every other class private attribute; and the six class members that looked like helpers but each document observable behaviour a caller needs -- `_split_key` interprets caller-supplied keying material, `Vendor._request` is the hook a subclass with a different registry source overrides, and `PyPCAPFile._decode` and `_get_decoder` record the warnings they emit and the guarantee they make. No `pcapkit/` line changed, so no changelog entry, following #686. `sphinx-build -b html` exits 0 with 56 warnings against 55 on `f0999858e`. The one added is `more than one target found for cross-reference 'Type'`, raised from the newly rendered `TraceFlow._foutio`, whose doc comment at `pcapkit/foundation/traceflow/traceflow.py:406` reads `#: Type[Dumper]: Dumper class.` -- Napoleon turns that bare `Type` into a cross-reference that five classes in the tree answer to. The same warning already fires four times at `f0999858e` from `engine.rst`, `reassembly.rst` and `traceflow.rst`, so this is a fifth instance of a standing ambiguity rather than a new kind of breakage, and no reference was orphaned. Qualifying that annotation would silence it, but the file is outside the scope of this change. Fixes #684
…enet (#684) The API reference should keep usage and extensibility clear while hiding the recipe. Measured against that, `docs/source/` was wrong in both directions: it documented a handful of module-level privates on nobody's surface, and it omitted 39 class private attributes that are squarely contract -- several of them the only undocumented member of a group whose siblings all have directives. * Removed 7 directives, all module-level privates: `esp._resolve`, `esp._CRYPTO`, `ngap._convert`, `ngap._revert`, `ngap._PYCRATE`, `ngap._PDU_LOCK` and the private stream adapter `pypcapfile._NamedStream`. A private helper, a lazily imported backend flag, an internal lock and a wrapper nobody constructs or subclasses. `_NamedStream`'s two nested members go with it, which is correct -- a member of a private class is reachable only through that class. * Added 39 `autoattribute` directives for class private attributes that carry contract. The clearest are the ones that were alone in being left out: `Extractor._flag_f`, the only one of ten `_flag_*` without a directive; `PCAP_CT._backend` and `PyPCAP._backend`, whose every sibling on the same class is documented; and the built-in `PCAP` and `PCAPNG` engines, which carried none of the private state block that all six third-party engines carry. The rest back a documented property, or are read and written directly by collaborators -- `Extractor._vfunc`, `_fext` and `EngineBase._extractor` are what `ext.rst` already teaches engine authors to use by name. * Documented the const enums' `_missing_` fallback once, on the `pcapkit.const` landing page, rather than on each of the 121 enumerations under `pcapkit/const/` that implement it. Looking a value up by an unassigned-in-range number mints a member instead of raising, which is deliberate per #647 and is the extensibility behaviour of the whole package; `conf.py` already names `_missing_` in `exclude-members`, so per-class directives would be fighting the project's own configuration to say 128 times what one section says better. `registry.rst` gains a sentence pointing at it, since every `register_*` there takes a code the enum must already resolve. * `CONTRIBUTING.md` records the tenet: what counts as contract, that per-option `_read_*` / `_make_*` pairs publish the data format and stay, that most class private attributes stay, that anything abstract or implemented across subclasses must be documented, and that the recipe to hide is chiefly module-level privates. Deliberately not changed: the `_read_*` / `_make_*` families, which outline the constructor contract and the data format for each option, parameter, chunk and cause; every other class private attribute; and the six class members that looked like helpers but each document observable behaviour a caller needs -- `_split_key` interprets caller-supplied keying material, `Vendor._request` is the hook a subclass with a different registry source overrides, and `PyPCAPFile._decode` and `_get_decoder` record the warnings they emit and the guarantee they make. No `pcapkit/` line changed, so no changelog entry, following #686. `sphinx-build -b html` exits 0 with 56 warnings against 55 on `f0999858e`. The one added is `more than one target found for cross-reference 'Type'`, raised from the newly rendered `TraceFlow._foutio`, whose doc comment at `pcapkit/foundation/traceflow/traceflow.py:406` reads `#: Type[Dumper]: Dumper class.` -- Napoleon turns that bare `Type` into a cross-reference that five classes in the tree answer to. The same warning already fires four times at `f0999858e` from `engine.rst`, `reassembly.rst` and `traceflow.rst`, so this is a fifth instance of a standing ambiguity rather than a new kind of breakage, and no reference was orphaned. Qualifying that annotation would silence it, but the file is outside the scope of this change. Fixes #684
Fixes #674.
What the defect actually is, and what #662 already changed about it
tests/_support.py'sload_modulewrites tosys.modulestwice over — themodule it was asked for, and a bare stub package for each parent of that
module's dotted name, via
ensure_package(tests/_support.py:160) — and tookneither back off again. The stub carries nothing but a
__path__, which is whatmade this so quiet: it is emptier than the real package rather than
differently-shaped, so the next test to walk
pcapkit.__all__did not fail onanything that looked like pollution. It reported the library as declaring no
exports.
The repro in the issue no longer fails as written, and that matters. Measured
on the issue's base and on
b34f132f6:#662 added
restore_module_table, an autouse fixture intests/conftest.pythatsnapshots and restores the
pcapkitregion around every test. That repairsthe table after the polluting test whether or not the helper was ever fixed, so it
masked the symptom. The mechanism was untouched, and it is still observable by two
routes the fixture does not cover:
The three failures are the ones the issue names, and
unittestneeds no flag toshow them: the stdlib runner reads no
conftest.pyat all, and it is a supportedway to run this suite. So the honest statement is that #662 hid the symptom from
pytestand left the defect in the helper — which is exactly the shape ofaccidental healing #662's own docstring warns against.
The blast radius was wider than the issue's one file
Pairing each suspect with
tests.project.test_public_apiunderunittest, onb34f132f6:tests.corekit.test_multidictbootstrap_core_modulestests.corekit.test_iobootstrap_core_modules+load_moduletests.utilities.test_exceptions_warningsbootstrap_core_modulestests.corekit.test_moduleload_modulealonetests.utilities.test_compatload_modulealoneTwo of the five leak through
load_moduleon its own, never touchingbootstrap_core_modules. Fixing only the helper the issue names would have leftthem leaking, which is why the fix sits at
load_module/ensure_package.The mechanism chosen, and how it handles absence
restore_modules_after(test)snapshots thepcapkitregion and puts it back onteardown through
addCleanup. The restoring itself isrestore_modules, alreadyin the file from #662 and already exact in both directions: it pops any name
under the prefixes that the snapshot does not hold, and rebinds the ones it does.
Absence is therefore restored as absence —
pcapkit,pcapkit.corekitandpcapkit.utilitiesdid not exist before the test ran and do not exist after it.A
dict.update(snapshot)would pass the other two directions and fail this one,and that is the direction the defect actually was.
Registered once per test however many times it is called, so
bootstrap_core_modulesannouncing itself and then making sevenload_modulecalls yields one cleanup, to the earliest snapshot — the only one taken before any
load had written anything. Re-snapshotting on the later calls would restore to a
table already holding the earlier loads, which is the original bug with more
steps.
ArrangedRestoreTests.test_the_first_snapshot_is_the_one_keptpins that.No call site changes. The loaders find the running
TestCasefrom the callingframes, which is the technique
sample_pathin the same module already uses anddocuments: a helper that must be handed the running test is a helper every call
site can forget to hand it to, and the forgetting is silent — five call sites'
worth, none of which looked wrong. The nearest enclosing
selfwins, which alsoresolves correctly for
tests/utilities/_harness.py'sbootstrap(), anintermediate frame with no
selfof its own. Atest=parameter is the escapehatch for a frame that genuinely has none.
The walk takes the nearest running test rather than merely the nearest local
named
self— see "what the cross-review changed" below for why that distinctionis load-bearing.
It also avoids touching
tests/corekit/test_multidict.py. That began as acoordination constraint — #667 was editing the same two
setUplines — and #667has since been closed unmerged, so the constraint is gone; the no-call-site-changes
design stands on its own merits and is unchanged.
Every caller, and whether it is now safe
bootstrap_core_modules— 7 call sites, all safe:tests/corekit/test_multidict.py:12tests/corekit/test_io.py:19tests/utilities/test_exceptions_warnings.py:14tests/utilities/_harness.py:91(→test_warning_emission,test_warning_filters,test_quiet_exceptions)tearDowntearDowntests/corekit/test_protochain.py:15isolate_modules(#662)tests/integration/test_module_loading.py:15isolate_modulestests/utilities/test_decorators.py:16isolate_modulesOne correction to the issue:
tests/corekit/test_protochain.pyis described ascalling
bootstrap_core_modulesinsetUpwith notearDown. That was truebefore #662; it now calls
isolate_modules(self)first and was already safe.load_module— the additional callers, all safe:tests/corekit/test_module.py:14andtests/utilities/test_compat.py:15,50leaked and no longer do.
tests/utilities/test_logging.py(11 sites) did notleak, because its
tearDownpurges — it is now covered by the helper regardless.tests/interface/test_core.py:130,133,184was already underisolate_modules.purge_modules— ~120 call sites, and it stays purge-only. Dropping areal module is not a change another test can observe: the next one that wants it
imports it again and gets the same thing from the same source. Binding something
else over the name is what re-importing cannot undo, so restoration is owed by
the helpers that bind, not by this one. Its docstring now says so, rather than
leaving the asymmetry looking like an oversight. The unsafe thing was never the
purge — it was what a caller did after purging, and that is now guarded.
Guards, so a future caller cannot get it wrong quietly.
ensure_packagerefuses to bind a stub for a test that has arranged neither a restore nor
isolation, and the loaders refuse when no running test can be found. Both name the
helper that fixes it.
Evidence
Fails without the fix, passes with it. The defect-level runs are in the tables
above. For the new tests specifically, the helper was re-run with its restore
neutered — post-fix signatures, pre-fix behaviour, so each assertion fails on its
own terms rather than the module failing to import:
The 11 are every new assertion the neutering reaches: all five borrowed cases, all
three stub names, and three of the four
ArrangedRestoreTestscases.Restoration asserted directly, not only the symptom.
_assert_module_table_unchangedcompares the wholepcapkitregion in all threedirections — additions gone, drops back, rebinds undone — and
test_the_stub_packages_are_absent_again_afterwardsasserts the absence of thethree specific names, with a recorded flag proving the stubs were actually bound
while the probe ran so the assertion cannot pass vacuously.
Coverage does not go backwards.
[tool.coverage.run] source = ["pcapkit"],and no
pcapkitline changed. Measured anyway overtests/corekit tests/utilities tests/test_support_helpers.py: thecoverage reportoutput is byte-identical before and after (TOTAL 40227 16348 9404 958 51%;diffof the two reports exits 0 with no output — re-measured afterthe rebase, so both sides share the same
pcapkit). Test counts rose from298 passed / 507 subtests to 304 / 515 — six tests and eight subtests added.
Version sensitivity. The frame walk and
TestCase.doCleanups()outsiderunwere exercised on CPython 3.10.21, 3.11.15, 3.12.14, 3.13.15 and 3.14.7; both
changed files byte-compile on all five.
f_localsis adictup to 3.12 and aFrameLocalsProxyfrom 3.13 (PEP 667), and.getanswers on both — verified,not assumed.
Wider run.
tests/corekit tests/utilities tests/project tests/interface tests/cli tests/protocols/transport tests/integration/test_module_loading.py tests/test_support_helpers.py tests/test_docstring_contract.py tests/test_tier_guard.py→ 600 passed, 1127 subtests, 7 failures, every one ofthem
FileNotFoundError: sample capturefromsample_path(this worktree has 6of the sample captures; the rest are generated, not committed). The integration
tier adds 51 more of the same. The full suite was deliberately not run — it
peaks over 40 GB RSS, and a full-suite green result would prove nothing here
anyway, since the full selection is precisely what masks this defect.
I am not claiming CI green, and CI cannot see this defect: every route that
exposes it either bypasses
tests/conftest.pyor runs the two files as the wholeselection. What the evidence above proves is that the four
unittestpairingsthat failed now pass, that the new tests fail against the unfixed helper, and that
the
pcapkitcoverage surface is untouched. What it does not prove is anythingabout Python 3.15, or about the integration and runtime tiers, which need sample
captures this worktree does not have.
What the cross-review changed
A cross-review on a different model (Sonnet) was briefed to falsify rather than
bless, and returned GOOD TO GO with three findings. All three were reproduced
independently before acting on them.
1. A real hole in the frame walk, now closed. Taking "the nearest frame local
named
selfthat is aTestCase" is not the same as "the running test". A freefunction with a parameter it happens to call
selfpresents an identical frame,so one handed a different, already-finished
TestCasewon the walk — and a cleanupregistered on a finished test is never run. Measured:
bootstrap_core_modules()returned normally, raised nothing, and left ten names bound in
sys.modulesafter the real test had finished cleanly.
The walk now requires the candidate to be running, via
TestCase._outcome, whichrun()sets beforesetUpand clears in afinally. Verified on CPython3.10.21 / 3.11.15 / 3.12.14 / 3.13.15 / 3.14.7 that it is set in all four phases a
loader is reachable from —
setUp, the test method,tearDown, a cleanup — andunset on an instance that has never run or has finished. An absent attribute is
distinguished from
Noneby a sentinel, so a future runtime that drops_outcomedegrades to the previous behaviour rather than rejecting every candidate.
Two alternatives were rejected. Checking that the frame really is a method call on
the candidate (
klass.__dict__[co_name].__code__ is frame.f_code) rejects adecorated test method, whose frame belongs to the undecorated function while the
class attribute is the wrapper — a false rejection breaks a working test, which is
worse than the hole. Requiring an explicit
test=at every call site forecloses ittoo, but at the cost of the property that makes this fix cover callers nobody has
written yet.
ArrangedRestoreTests.test_an_idle_test_case_in_a_local_named_self_is_not_usedpins it, and fails on its own against a name-only walk (
1 failed, 24 passed).2.
tests/cli/test_main.pyhas the same defect by an independent route, and thisPR does not fix it. It hand-rolls its own
sys.moduleswrites and importstests._supportnot at all, which is exactly whytests/conftest.pynames it asthe witness for #662's suite-wide guard. Paired under
unittest:Identical on
origin/mainand on this branch — measured both ways, so it isneither caused nor cured here. Same shape as #674, different mechanism, and it
wants its own change; happy to file it.
3. The #667 rationale was stale. Corrected above.
The cross-review also independently re-derived the numbers in this description —
the
298/507→303/515test counts, the byte-identical coverageTOTAL, thef_localstype per version, #662's labels and changelog absence — and found nooverstatement beyond the #667 reference. It noted one looseness worth repeating
here: "no
pcapkitline changed, so coverage cannot move" is imprecise as logic,since a fix that changes which paths execute inside unchanged code could in
principle shift the report. The claim does not rest on that reasoning — it was
measured directly, and the reviewer reproduced the byte-identical result.
Labels, and no changelog entry
Labelled
testonly.fixwas considered and rejected: the label set feedsthe release notes,
fixis for defects in the shippedpcapkitpackage, and thischanges zero lines of it. The precedent is exact — #662,
fix(tests): restore sys.modules after a test installs a stand-in module, carriestestalone, andissue #674 is itself labelled
test.No changelog entry, for the same reason and the same precedent: every entry in
docs/source/changelog/1.5.0.rstcorresponds to a change inpcapkit/, andneither #660 nor #662 appears there. A test-helper restoration gap is invisible to
anyone installing the library. Happy to add one if you would rather the 1.5.0 log
record it.
One separate defect found on the way, not fixed here
tests.utilities.test_compatis the fifth row of the table above and is the onethat still fails when paired with
test_public_api— with the same 10 errorsbefore and after this change, so it is neither caused nor cured by it. It is a
different mechanism:
test_python35_fallback_implementationsfakessys.version_info = (3, 5)and, under that fake, executespcapkit/utilities/compat.py:143(from aenum import StrEnum). On Python ≥ 3.11that is the first-ever
import aenumin the process, because the real-version loadtakes the stdlib
enum.StrEnumbranch instead — soaenum/_common.py:17(
pyver = _sys.version_info[:2]) caches(3, 5)permanently. A laterimport pcapkitthen reachesaenum/_enum.py:1638(if pyver < PY3_6:), takesthe Python-≤3.5 branch, and dies at line 1640 with
AttributeError: 'TransportProtocol' object has no attribute '__set_name__'.Confirmed with a standalone two-step script involving no
unittestormock.purge_modules/ISOLATED_PREFIXESonly cover thepcapkitprefix, so nothingpurges
aenum. Also masked under plainpytest, bypytest_sessionstart's eagerimport pcapkitwarmingaenumcorrectly first. Not tracked by any open issue(checked #660, #439, #575, #514, #647/#677). I have left it alone — it is not
#674 and wants its own review; say the word and I will file it.