For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Types and contracts

Why does monkeypatching break agent edits?

Rule AR-18

No monkeypatching in production code. In tests, patch through the fixture/mock API at the import site.

Monkeypatching replaces a function or attribute while the program runs, so the code that executes is not the code in the file that defines it. An agent that edits the original can't see the replacement, and one that edits the replacement doesn't know what it stands in for. In production code, write the new behaviour as a function or class that is called directly. In tests, use the framework's patch API on the name the code under test looks up, so the replacement is scoped, named and undone.

Why it happens

Python. Modules and classes are mutable objects, and module.name = other or Class.method = other rebinds a name for every later reader. The patch can live anywhere that runs before the call: an import-time statement, a conftest.py, a plugin. The reader of the file that defines charge sees no sign of it.

The standard library's mock docs state the rule that makes tests work and that trips up edits: patch() "works by (temporarily) changing the object that a name points to with another one. There can be many names pointing to any individual object, so for patching to work you must ensure that you patch the name used by the system under test." And: "you patch where an object is looked up, which is not necessarily the same place as where it is defined." Their example: if b.py does from a import SomeClass, patching a.SomeClass "will have no effect on our test", because b already holds a reference to the real class; patch b.SomeClass. If b does import a and uses a.SomeClass, patch a.SomeClass. The pytest docs say the same: "Prefer patching the reference that your code uses instead of patching the original object in the standard library", with from os import getcwd meaning you patch the name in your own module.

That is why a refactor that moves a function can leave a patch silently aimed at a name nobody looks up any more. The string form of the target ("payments.client.charge") is invisible to rename tools and to caller searches. CH-02 walks through that failure with an autouse fixture, and AR-34 and AR-35 cover the pytest side; this page does not repeat them.

Mock objects add a second blind spot. A plain mock accepts any call. The mock docs describe the fix: with autospec, "any functions and methods (including constructors) have the same call signature as the real object", so a call with the wrong arguments raises TypeError, and the mocks "fail in the same way as your production code if they are used incorrectly". Ruff's PT008 flags the lambda form of a patch, because the lambda "better conveys the intent" as return_value and doesn't adapt to changes in the patched function's signature.

TypeScript and JavaScript. ES modules are harder to patch. MDN states that imported bindings are "live bindings because they are updated by the module that exported the binding, but cannot be re-assigned by the importing module", and the example myValue = 3 throws a TypeError. MDN also describes the module namespace object as sealed. So production code in TypeScript patches by other routes: assigning onto a property of an exported object (api.fetchUser = wrapped), changing a prototype, or setting a global. These are all runtime rebinds that no import or type declaration records. Test runners offer module mocks: Jest's jest.mock replaces a module with an auto-mocked version when it is required (for the file that calls it), and Vitest's vi.mock substitutes all imports of the given path with another module.

Bad and good

A payments package wants to log every charge and retry on timeouts.

python# bad: payments/__init__.py (production monkeypatch) from payments import gateway _orig_charge = gateway.charge def _charge_with_retry(customer_id, amount_cents, **kw): log.info(f"charge {customer_id} {amount_cents}") for attempt in range(3): try: return _orig_charge(customer_id, amount_cents, **kw) except TimeoutError: continue raise gateway.charge = _charge_with_retry # every caller of gateway.charge now runs this
typescript// bad: payments/index.ts (production monkeypatch) import { gateway } from "./gateway"; // gateway.ts: export const gateway = { charge, refund }; const origCharge = gateway.charge; (gateway as any).charge = async (customerId: string, amountCents: number) => { console.log("charge", customerId, amountCents); for (let attempt = 0; attempt < 3; attempt++) { try { return await origCharge(customerId, amountCents); } catch (e) { continue; } } throw new Error("charge failed"); };

An agent asked to add a currency parameter to gateway.charge edits the definition, updates the direct calls it can find, and checks that the type checker passes. The wrapper still takes (customer_id, amount_cents, **kw) and forwards what it gets, so nothing fails until a path that goes through the patch drops the new argument. The patch only exists if payments/__init__.py has been imported first, so even the set of callers that get the retry depends on import order (AR-06).

python# good: payments/charging.py from payments import gateway def charge_with_retry(customer_id: str, amount_cents: int, currency: str = "usd") -> gateway.ChargeResult: log.info(f"charge {customer_id} {amount_cents}") last: Exception | None = None for _ in range(3): try: return gateway.charge(customer_id, amount_cents, currency) except TimeoutError as exc: last = exc raise RuntimeError("charge failed") from last
typescript// good: payments/charging.ts import { charge, type ChargeResult } from "./gateway"; export async function chargeWithRetry( customerId: string, amountCents: number, currency = "usd", ): Promise<ChargeResult> { console.log("charge", customerId, amountCents); let last: unknown; for (let attempt = 0; attempt < 3; attempt++) { try { return await charge(customerId, amountCents, currency); } catch (e) { last = e; } } throw new Error("charge failed", { cause: last }); }

Callers import charge_with_retry and a search for it finds every one that gets retries. A signature change in gateway.charge is an error in this one wrapper, since it calls charge by name with typed arguments.

In tests, the same rule applies one level down. Patch the name the code under test looks up, and ask for a signature-checked mock:

python# good: tests/test_checkout.py from unittest.mock import patch from payments import gateway def test_checkout_charges_the_cart_total(): with patch("checkout.charge", autospec=True) as charge: # checkout.py does `from payments.gateway import charge` charge.return_value = gateway.ChargeResult(ok=True, charge_id="ch_test") receipt = checkout("cus_42", [1200, 800]) charge.assert_called_once_with("cus_42", 2000, "usd") assert receipt.charge_id == "ch_test"
typescript// good: checkout.ts takes the dependency as a parameter, test passes a stand-in export async function checkout( customerId: string, cents: number[], charge: (id: string, amount: number, currency: string) => Promise<ChargeResult> = defaultCharge, ): Promise<Receipt> { const result = await charge(customerId, cents.reduce((a, b) => a + b, 0), "usd"); return { chargeId: result.chargeId }; } // test const calls: Array<[string, number, string]> = []; const fake = async (id: string, amount: number, currency: string) => { calls.push([id, amount, currency]); return { ok: true, chargeId: "ch_test" }; }; await checkout("cus_42", [1200, 800], fake);

The Python test names its target at the point of use, is undone when the with block ends, and fails with TypeError if checkout calls charge with arguments the real function wouldn't accept. The TypeScript version avoids patching: the dependency is a typed parameter with a default, so the test passes its own function and the type checker verifies it.

Trade-offs and when monkeypatching is acceptable

  • In tests it is the practical tool. monkeypatch and patch undo themselves, pytest's docs list the methods (setattr, setitem, setenv, chdir, context and more) and say all changes "will be undone after the requesting test function or fixture has finished". For code that wasn't written with injection in mind, a scoped patch is the cheapest way in.
  • Passing the dependency in is more work up front. A parameter or constructor argument costs a line at the definition and at each call site. It buys a call the type checker can follow and no patch to chase.
  • Patching builtins and library internals is risky. The pytest docs warn that patching builtins like open or compile "can break pytest's internals", and the same goes for the standard library and third-party libraries. Scope it with monkeypatch.context().
  • Environment, clock and filesystem patches are an acceptable use. setenv, chdir and a fixed clock don't hide a call; they change an input. Keep them in a named fixture.
  • Instrumentation and tracing tools often patch by design. Keep them out of application code paths, in one module that says what it patches.
  • Gradual migration. A legacy patch you can't remove yet should live in one file with a comment naming the target and the reason (AR-19 covers the neighbouring pattern of assigning methods onto objects).

Enforce it

ToolSettingWhat it catchesChecked
ruffB010 set-attr-with-constantsetattr(obj, "name", value) with a literal name, which should be an assignment. It does not catch module.name = value2026-10-30
ruffPT008 pytest-patch-with-lambdaa lambda used as the replacement in a pytest patch; use return_value. A lambda that uses its arguments is not flagged2026-10-30
pyrightreportAttributeAccessIssue (error in basic, standard and strict)attribute access problems, per its docs description; checked with pyright 1.1.414: it also covers assigning an undeclared attribute ("Attribute is unknown") and assigning a value of the wrong declared type2026-10-30
mypydefault checkingassigning to a method or module attribute with an incompatible type (checked with mypy 2.4.1: method-assign and assignment on a method patch, assignment on a module attribute, all on by default)2026-10-30
unittest.mockpatch(..., autospec=True), create_autospeca mocked call with arguments the real signature doesn't accept (raises TypeError)2026-10-30
pytestmonkeypatch.setattr(..., raising=True) (the default; the docs describe raising as controlling whether a missing target errors)a patch aimed at a name that doesn't exist2026-10-30
typescript-eslintno-explicit-any (in recommended)the (x as any).prop = ... cast that most TypeScript monkeypatches need2026-10-30

I found no rule in the ruff, pyright or typescript-eslint pages checked that flags a bare module.name = value rebind as monkeypatching. Review for it, and use the type checker's attribute rules to catch the cases where the module or class doesn't declare the name.

Rule block for AGENTS.md / CLAUDE.md

markdown## Monkeypatching - Don't assign over functions, methods or module attributes in production code (`module.fn = ...`, `Class.method = ...`, `(x as any).prop = ...`, prototype edits). Write a wrapper function and call it, or pass the dependency in as a parameter. - In tests, use `monkeypatch.setattr` or `unittest.mock.patch` on the name the code under test looks up (`patch("checkout.charge")` when checkout.py did `from ... import charge`). Prefer `autospec=True`. - No autouse fixtures that patch application code unless the fixture is named for what it does and the patch is mentioned in the test's own docstring or setup. - Before changing a function's signature, search for patches of it (`patch(`, `monkeypatch.setattr`, `.name =`) and update them in the same change.

Edge cases

  • **patch.object(Class, "method")** names the attribute with a string, so it has the same rename problem as a dotted-path string. Prefer the object form where you can, and always with autospec.
  • Decorators that replace functions (@cache, @retry) rebind the name at definition time. Preserve the signature (AR-12).
  • **from x import y captured before the patch.** A module that already imported y keeps the real function, per the mock docs. Patch the importer's name.
  • **monkeypatch.setattr with a string target** like "module.attr" is resolved at runtime. A renamed target fails when the test runs, since raising defaults to erroring on a missing target.
  • Test doubles for TypeScript classes are cleaner with a shared interface (AR-15) that both the real class and the fake implement.
  • **Patching in conftest.py or setup files** reaches tests that never mention it. See CH-02.

What LLMGRAPH shows

LLMGRAPH does not read string patch targets: a patch("checkout.charge") never appears in the callers of charge. A production rebinding shows as passed as a value in 1 place notes, not as a blind spot.

References

  • Python mock library, "where to patch", patch(), autospec: https://docs.python.org/3/library/unittest.mock.html#where-to-patch
  • pytest, how to monkeypatch and mock modules and environments: https://docs.pytest.org/en/stable/how-to/monkeypatch.html
  • Ruff PT008: https://docs.astral.sh/ruff/rules/pytest-patch-with-lambda/
  • Ruff B010: https://docs.astral.sh/ruff/rules/set-attr-with-constant/ · rule list: https://docs.astral.sh/ruff/rules/
  • Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
  • MDN, import (live, read-only bindings; sealed namespace objects): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
  • typescript-eslint no-explicit-any: https://typescript-eslint.io/rules/no-explicit-any/
  • Chimera CH-02, pytest autouse fixture that monkeypatches a service: chimeras/CH-02-pytest-autouse-monkeypatch.md

Changelog

  • 2026-10-30: draft 1.
  • 2026-10-30: docs checks resolved.
  • 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1)
  • 2026-10-30: copy-gate wording fixes
  • AR-06 Why does my agent not see what importing this module does?
  • AR-19 Why does reassigning methods hide callers from agents?
  • AR-34 Why does my agent break pytest fixtures it can't see?
  • AR-35 Where should I patch in pytest?
  • CH-02 Why do my pytest tests pass for a reason I can't see?

Chimeras that use this rule

  • CH-02 Why do my pytest tests pass for a reason I can't see?

Last verified

For agentsthis page as Markdown/agent-readable/ar-18.mdllms.txt