A crapkit note · measured, not remembered
If your tests drive a command line through subprocess.run, upgrading the plugin takes those entry points to 0% and warns about nothing. Two configuration lines bring them back. Here are the four runs that show it.
One module, one suite, one tree. The only thing that moved between those two numbers is a plugin major version, and after it a single config key.
pytest-cov 7.0.0 shipped on 2025-09-09. Its changelog opens with six words.
7.0.0 (2025-09-09)
------------------
* Dropped support for subprocesses measurement.
The entry says why. The old mechanism was a .pth file the plugin installed into site-packages, and by the plugin's own account "there was no way to opt-out and it created bad interations with coverage's new patch system added in 7.10". The same entry adds the floor: "This release also requires at least coverage 7.10.6."
So the capability did not disappear, it moved. coverage 7.10.0, released 2025-07-24, added a [run] patch setting, and subprocess is one of the patches it takes. Nothing migrates your old setup, because there was no old setup to migrate: the dropped behaviour was on by default and had no config key. That is the whole shape of the problem. A repository that never wrote a line about subprocess coverage was relying on it, and a repository that never writes a line after the upgrade stops getting it.
Anyone whose real code runs in a child process.
The shape is a test that starts a program instead of importing it. A CLI's end-to-end suite spawning python -m yourtool. A manage.py command driven through a shell. A packaging or migration script exercised by running it. Any of these, and the functions those children reach are the ones you can no longer see.
Two things make it worse than an ordinary coverage dip. The first is that these are usually the most valuable tests in the suite, the ones that prove the shipped entry point works rather than that an imported function returns the right value. The second is that a coverage number feeds gates: a threshold in CI, a diff-coverage check on a pull request, a per-function score. Those gates do not read "unmeasured". They read a zero, and a zero is a claim about your tests.
Nothing warns. The suite stays green and the number underneath it stops being true.
pytest exits 0. The test count does not move. The total drops by whatever the children used to carry, which on a repository with a small unit suite and a large CLI suite can be most of it, and the modules only a child ever reaches read 0%. If you are watching the total you might notice; if the upgrade arrived in a batch with nine other bumps, you will read the dip as somebody else's.
What it costs downstream depends on what consumes the number. crapkit scores every function as CRAP = ccn² × (1 − cov)³ + ccn, so a function at complexity 8 with full coverage scores 8, and the same function measured at 0% scores 72. Nothing about the function changed. Nothing about its tests changed. The plugin changed.
The second half of the trap. Adding the key is not enough on its own. coverage 7.9 and earlier do not know [run] patch, so they answer it with a CoverageWarning, and pytest-cov 6.2.0 and later file CoverageWarning under once::CoverageWarning. The key is accepted, the warning appears once in a block nobody reads, and no child is measured. That is the same silence, one warning louder.
One asks coverage to follow the child. The other makes sure the coverage you get can hear the question.
In pyproject.toml:
[tool.coverage.run]
patch = ["subprocess"]
In whatever pins your test dependencies, the floor that key needs:
coverage>=7.10.6
The .coveragerc spelling of the first line is patch = subprocess under [run]. Both lines are load-bearing and neither fails loudly without the other. Drop the key and pytest-cov 7 measures no child at all. Keep the key on an older coverage and you get the warning described above and the same zero.
Two consequences worth knowing before you commit it. parallel follows automatically, so each process writes its own data file and coverage combines them, which means .coverage.* belongs in your ignore list next to .coverage. And the measurement is real work in every child, so a suite that spawns thousands of short-lived processes will feel it.
One module, four runs, two pins of the plugin and two settings of the key.
The module is src/crapkit/cli/admin.py, which holds crapkit's init, doctor and watch commands. Nothing imports it in a test. Its only exercise is tests/e2e/test_init_doctor_e2e.py, which spawns python -m crapkit in a throwaway git repository for every assertion it makes. That makes it a clean instrument: if subprocess measurement stops, this module goes to zero and says so.
The reproduction is committed as tools/notes/pytest_cov7_repro.py. It builds one virtualenv per pin, installs crapkit editable with its dev extra into each, and runs that single e2e file four times:
python -m pytest -q -n 0 -p no:randomly tests/e2e/test_init_doctor_e2e.py \
--cov=crapkit --cov-branch --cov-report=json:cov.json
The patch key is toggled through COVERAGE_RCFILE, pointing at a generated .coveragerc outside the tree, so the repository under measurement is never edited between runs. The counts land in pytest_cov7_repro.json beside the script, and a contract test in the unit suite fails if this page and that file ever disagree.
| pytest-cov | coverage | [run] patch | admin.py statements | What it means |
|---|---|---|---|---|
| 6.3.0 | 7.16.0 | absent | 324/521 | the plugin measured the children itself, which is what everybody was silently relying on |
| 6.3.0 | 7.16.0 | present | 324/521 | both mechanisms available, same answer. The key is safe to add before you upgrade |
| 7.1.0 | 7.16.0 | absent | 0/521 | no child measured. Every command function in the module reads 0% coverage |
| 7.1.0 | 7.16.0 | present | 324/521 | coverage's patch does the job the plugin used to do |
Python 3.11.2, pytest 9.1.1, pytest-xdist 3.8.0, coverage 7.16.0 in both environments. All four runs exited 0. The suite passed every time. 324 statements is 62% of the module, and losing them is the difference between a measured CLI and one that reads as never tested.
Row two is the practical one. The key is inert on a plugin that does not need it, so it can go in on its own commit, today, before anyone upgrades anything.
A tool that scores its own repository noticed its own numbers were wrong.
crapkit ranks every function by complexity times uncovered risk and refuses commits that make the score worse, and its CI runs that scoring against crapkit itself. The job kept passing. It was always going to keep passing: the ratchet pardons standing debt and the gate judges only the functions a diff touches, so a repo-wide drop in measured coverage is not something either of them is built to refuse. What moved was the number underneath. Every cmd_* in the CLI families started scoring untested while its own tests ran green, on a tree whose tests had not changed. Reading the coverage artifact instead of the verdict is what sent us to the plugin's changelog. The four runs above are that finding cut down to one module, so anyone can check it in ten minutes without installing the tool that found it. For what crapkit does with a coverage number once it trusts one, there is the handbook.