Uh oh!
There was an error while loading. Please reload this page.
Test modules must not build state while being collected - #588
Conversation
pytest imports a module in order to collect it, so module-level code runs before any test, any fixture, and the isolation in tests/conftest.py. That is how #567 (units switched on at import) and #505 (two Stokes solves at import) reached development. test_0050_utils.py, test_0120_data_property_access.py and test_0130_field_creation.py were converted scripts with no test function in them: 0120 and 0130 printed their exceptions instead of raising, so they could not fail, and 0050's only assertion was disabled by its name. Each is now fixtures and tests over its original subject. The auditor check is a delta rather than uw_object_count == 7, which is only true in a fresh process running that file alone. conftest.py fingerprints the uw_object count, the active model's reference quantities and the strict-units flag around each module's collection, and fails the run naming the module and what moved. Eighteen modules already do this and are exempted by name (#587); the list is a ratchet, so nothing new lands. test_0051_collection_state_guard.py runs pytest on an offending module and on a clean one. scripts/test.sh no longer comments out test_0050. Closes#505, #575. Underworld development team with AI support from Claude Code
… offender test_0742 copies the live conftest into a pytester sub-run together with a module that leaks units at import, because what it pins is that the module-scoped reset survives exactly that. The guard refused that module and ended the sub-run before it reached its assertion. UW_TEST_COLLECTION_GUARD=off turns the guard off for such a run. test_0742 sets it for its sub-run only, and test_0051 covers both settings, so the off switch cannot silently become the default. Underworld development team with AI support from Claude Code
Aborting from pytest_collection_finish leaves an xdist worker part-collected. The controller then reports INTERNALERROR> assert not crashitem, (crashitem, node) with a return code of 3 and nothing a reader can act on, which is the mode CI runs in. pytest.exit(returncode=4) does the same. The check moves into a pytest_make_collect_report wrapper, which sees the report before it is dispatched, and marks the offending module's collection failed with the fingerprint difference as its message. Measured on a module that builds a mesh at import: serial RC=2 ERROR tests/test_offender.py xdist -n 2 RC=1 10 passed, 1 error clean tree RC=0 both runners test_0051 covers the distributed case, and asserts INTERNALERROR is absent rather than only that the run failed. Underworld development team with AI support from Claude Code
lmoresi
commented
Aug 16, 2026
Adversarial reviewReviewed against the branch head ( 1. The ratchet matches by suffix, so it exempts more than the eighteen files. 2. The auditor delta could pass for the wrong reason. 3. 4. The off switch is process-wide and unqualified. 5. Checked: the fingerprint does nothing collective. It reads Underworld development team with AI support from Claude Code |
Closes#505. Closes#575. Closes one of the gaps in #570.
pytest imports a test module in order to collect it, so anything a module does at
module level runs before any test, any fixture, and any isolation
tests/conftest.pyprovides. Two defects have reacheddevelopmentthat way:#567, where a module switched the units system on at import and the first
module-scoped fixture in that worker built its mesh under dimensional
coordinates; and #505, where a module ran two Stokes solves at import, so
--collect-onlysat insideSNESSolvelooking like a silent death.PR #574 fixed the first instance. This fixes the practice.
The three files that had no tests in them
test_0050_utils.py,test_0120_data_property_access.pyandtest_0130_field_creation.pywere converted scripts. Between them they built twomeshes, a swarm, two Stokes solvers, and solved both — all at module level — and
contained no test function at all. 0120 and 0130 wrapped every statement in a
try/exceptthat printed the exception, so a broken.dataproperty or aduplicate
field_idprinted a cross and the run stayed green. 0050's onlyassertion was disabled by the name
dont_test_auditor.Each is now written as fixtures and tests, and each keeps its original subject:
fields the first already owns. The auditor check is a delta rather than the
uw_object_count == 7it used to assert: the counter is process-wide andmonotonic, so an absolute count is only true in a fresh process running that
file alone, which is why the assertion could not survive being enabled. Its
control is that reading the auditor does not itself move the count.
.datais read back and comparedwith what was written, twice, which is what exercises the cache.
field_ids, and.arrayis shaped by the variable's own component count.Importing
test_0050_utils.pycost 2.0 s of the 5.2 s import with warm JIT andmesh caches; the 20+ minutes recorded on #505 was that work on a cold cache.
The guard
tests/conftest.pyfingerprints three pieces of process-global state —the count of
uw_objects created, the active model's reference quantities, andthe strict-units flag — before and after each module is collected, and reports
the difference as a collection error against that module. The fingerprint is
skipped when underworld3 is not importable, since conftest is loaded before the
package is necessarily installed in CI.
A collection error rather than a session abort, because the first form aborted
from
pytest_collection_finishand that leaves an xdist worker part-collected:the controller reports
INTERNALERROR> assert not crashitemat return code 3,which is the mode CI runs in.
pytest.exit(returncode=4)behaves the same. Thecheck now lives in a
pytest_make_collect_reportwrapper, which sees the reportbefore it is dispatched. Measured on a module that builds a mesh at import:
ERROR tests/test_offender.py-n 210 passed, 1 errortests/test_0051_collection_state_guard.pyruns pytest on a module that offendsand on one that does not, loading the hooks out of the real
conftest.pybypath. The second run is the control: without it the first proves only that
something failed.
UW_TEST_COLLECTION_GUARD=offturns the guard off.test_0742needs it: thattest copies the live conftest into a pytester sub-run together with a module
that leaks units at import, because what it pins is that the module-scoped reset
survives exactly that. The guard refused the module and ended the sub-run before
it reached its assertion — correct behaviour on a deliberately generated
offender.
test_0742now sets the variable for its sub-run only, andtest_0051covers both settings so the off switch cannot become the defaultwithout a test failing.
The ratchet
Eighteen modules already move that state, so they are exempted by name in
_KNOWN_COLLECTION_TIME_WORK. The guard therefore stops the practice spreadingtoday rather than after eighteen files have been rewritten. The list is #587,
with
test_0601_mesh_vector_calc.pynamed first because it is the only entrythat moves the units state —
use_strict_units(False)at import, which is the#567 mechanism itself, reaching every module collected after it.
scripts/test.shdisablestests/test_06*pywholesale, which is why 0601'simport-time flip has never been noticed by our own CI: it is collected by any
whole-directory run and executed by none of ours.
scripts/test.sh
tests/test_0050*pyis no longer commented out. It was disabled because theauditor assertion could not pass, which the delta form fixes.
Verified
pytest tests/ --collect-onlyexits 0 with 2741 tests collected; before, itreported eighteen modules and, with the three files above, ran two Stokes
solves.
file: it named that file and no other.
./uw test: 1504 passed, 32 skipped, 2 xfailed.The fingerprint is not a general leak detector. It sees objects built and the
units state; a module that writes an environment variable, a PETSc option, or a
monkeypatched attribute at import passes it. The rule it enforces is the
stricter and simpler one — build nothing while being collected.
Underworld development team with AI support from Claude Code