An error-handling test was supposed to make a file operation fail and check the application's response. Its mock also affected pytest's own file operations.

This happened in agent-cost, a CLI I maintain. The test checked that an OSError from operations such as os.fstat became the application's BillingPlanError while loading a billing-plan file.

PR #24 records the failure with pytest 9.2.0: its capture machinery called os.fstat on pytest's own file descriptors, and the test's unconditional fake raised before load_billing_plan was reached.

The fix changed the tests. The application's file-loading implementation stayed as it was.

A module-qualified patch can still change a shared object

Before the fix, the relevant test looked like this:

def failing_fstat(fd): raise _oserror_on(path) monkeypatch.setattr(bp.os, "fstat", failing_fstat) with pytest.raises(bp.BillingPlanError): bp.load_billing_plan(path) 

This is an excerpt from the project test, not a standalone reproducer. bp is the agent_cost.billing_plan module; path and _oserror_on come from its test setup.

Writing bp.os does not confine the change to billing_plan.py. That module imports os, so under normal import behavior it refers to the same module object:

import os from agent_cost import billing_plan as bp assert bp.os is os 

Python resolves previously imported modules through sys.modules; the module-cache documentation describes this behavior.

Replacing the fstat attribute on that shared object is visible to other users of the object, including pytest.

The original fake did not inspect its argument. Every file descriptor received the same failure. A descriptor is an integer handle for an open file or another resource. The billing-plan descriptor and pytest's capture descriptors were all treated alike.

Restoring a patch at the end of a test does not isolate its effects while the test is running.

Fail only for the target file descriptor

The replacement checks whether the descriptor refers to the plan file. Other calls delegate to the real function.

The central pieces are:

_REAL_FSTAT = os.fstat def _is_fd_of(fd, path): try: st = _REAL_FSTAT(fd) except OSError: return False target = os.stat(path) return (st.st_dev, st.st_ino) == (target.st_dev, target.st_ino) def failing_fstat(fd): if _is_fd_of(fd, path): raise _oserror_on(path) return _REAL_FSTAT(fd) 

The helper compares the device and inode returned for the open descriptor with those returned for the target path.

It uses _REAL_FSTAT, captured before patching, so the identity check does not call the fake recursively.

The plan file still encounters the error under test. Calls involving pytest's own descriptors use the real function. The patch target has not changed; the set of calls that fail has.

This helper identifies the test's target file. It was not introduced as a general solution for file identity or concurrent filesystem changes.

Apply the boundary to read and close, too

The patch also narrows the os.read and os.close fakes:

Operation Target plan-file descriptor Other descriptors
fstat Raise the synthetic error Call the real function
read Raise the synthetic error Call the real function
close Close it, then raise the synthetic error Perform the real close only

For close, the order matters. The test must decide whether the descriptor is its own before closing it:

mine = _is_fd_of(fd, path) real_close(fd) if mine: raise _oserror_on(path) 

Closing successfully and then raising is the behavior this particular test models. It is not a model of every possible close failure.

What the recorded checks show

GitHub Actions retains the before-and-after runs:

Run Recorded result
Before the test fix The Test step failed on Python 3.10–3.14; Python 3.9 and the packaged-install smoke test passed
After the test fix All seven jobs passed

Those outcomes belong to that CI matrix. They do not establish that Python 3.9 itself prevents the problem.

The PR and fix commit also report 549 passing tests with both pytest 9.1.1 and 9.2.0. Those are recorded verification results, not a new run performed for this article.

The full PR changes only the test file and adds workflow_dispatch to let CI run manually. No production loading code changed in this fix.

A shorter patch lifetime is only one boundary

The pytest monkeypatch guide already warns that patching standard-library functions can break pytest itself. It also describes MonkeyPatch.context() for limiting a patch to a block.

A smaller block limits lifetime. It does not stop pytest from using the shared function while that block is active.

This fix limits which file operations receive the synthetic failure. It does not add context(), and it does not claim a new discovery about monkeypatching in general.

For this test, the missing boundary was the descriptor the call operated on. The fake needed to fail the application's operation while letting the test runner continue doing its own work.

AI assistance was used to draft and edit this article.