Ruff 0.17.3, released on 27 October, added a preview rule called shared-clock-mock. It flags tests that patch time.sleep, time.monotonic or asyncio.sleep through a module that only imported time. Our view: turn it on in any suite with threads or async tasks, because the bug it catches makes a test's assertions count calls the code under test never made. But read the fix carefully. Applied as written, it can break a test that was otherwise fine, and Ruff stays quiet when it does.
What the rule catches
The trap is old. The Python docs on where to patch say "you patch where an object is looked up". Teams learned that and wrote patch.object(worker.time, "sleep", ...), which looks like patching where worker looks it up. But if worker did import time, then worker.time and time are the same module object. The patch replaces sleep for every thread in the process.
The pull request by Charlie Marsh explains it with real tests. In a reduced LangChain test, a recorder asserts the polling intervals are [2.0, 7.0]; another thread calling time.sleep(1) in between turns that into [2.0, 1, 7.0]. The ecosystem run "adds 601 diagnostics across eight projects". The recommended fix is to replace the module's binding instead: patch.object(worker, "time", SimpleNamespace(sleep=sleep)).
The run
We installed ruff==0.17.3 and pytest 9.2.0 on Python 3.13.7 in a fresh venv. The code under test is a retry() that calls a fetch() three times and sleeps two seconds between attempts. fetch() stands in for a 5 ms request and uses time.perf_counter() to time itself, which matters later. A background heartbeat thread calls time.sleep(0.001) fifty times, the way a metrics or keep-alive thread would.
Each test patches sleep, calls retry(), and asserts the mock was called twice. We ran each test file 100 times.
| Test | Ruff | Result over 100 runs |
|---|---|---|
patch.object(worker.time, "sleep", sleep) | flagged | 100 failed, assert 51 == 2 every time |
The documented fix: patch.object(worker, "time", SimpleNamespace(sleep=sleep)) | clean | 100 failed, AttributeError: 'types.SimpleNamespace' object has no attribute 'perf_counter' |
The fix with every function the module uses: SimpleNamespace(sleep=sleep, perf_counter=time.perf_counter) | clean | 0 failed |
monkeypatch.setattr(worker.time, "sleep", sleeps.append), no other thread | flagged | 0 failed |
Show the Ruff output for the first test
shared-clock-mock: Changing `time.sleep` may affect unrelated threads or tasks --> tests/test_shared.py:9:23 | 7 | hb = heartbeat.start() 8 | sleep = Mock() 9 | with patch.object(worker.time, "sleep", sleep): | ^^^^^^^^^^^^^^^^^^^^ 10 | worker.retry() 11 | hb.join() | help: Replace the binding in the consuming module with a private clock
The first row is the bug the rule exists for. The mock counted 51 calls: two from retry() and 49 from the heartbeat thread, which found the mock instead of the real sleep and ran through its remaining beats at once.
It is usually not that loud
Our heartbeat was busy enough to fail every run. Real suites rarely are. We slowed it to three beats 20 ms apart and started it 15 ms before the patch: in 200 runs, the mock saw two calls 14 times and four calls 186 times. So the same test would pass about one run in fourteen. With a quieter thread, that becomes the test that fails once a week in CI.
That is why a lint rule is the right tool here. Rerunning will not tell you the patch is shared. Reading the patch target will, and Ruff reads every one.
The fix needs the whole clock
The second row is our finding. SimpleNamespace(sleep=sleep) replaces all of worker.time, so any other time.* call in that module now fails. Ours was perf_counter. Ruff passed the file because the patch is now local, which is the rule's whole job. The test then crashed.
The PR knows this. In its Superset case it says "a private clock for superset.dashboards.api needs both monotonic and sleep". The rule's docs say to replace the binding "with an object that provides the functions worker uses". The rule's code example does not show that step, and that example is what a person, or an agent asked to "fix the Ruff warnings", will copy.
Our failure was loud. The quieter version is a module that calls time.time() only on an error path. The namespace lacks it, the happy-path test passes, and the error path now raises inside a test that never exercised it.
What to do
- Enable it in preview for test code:
ruff check --preview --select shared-clock-mock tests/. - When you fix one, list every
time.orasyncio.attribute the consuming module uses (grep -o 'time\.[a-z_]*' app/worker.py | sort -u) and put each one in the namespace, passing the real function through where you do not want a fake. - Treat flagged single-threaded tests as latent. Our recorder test passed every run, because nothing else was sleeping. It will keep passing until someone adds a background thread.
Caveats. A toy project built to show both behaviours, and the failure rates depend on our timings, not yours. The rule is in preview, so its scope and message may change. The PR documents a false positive when an autouse fixture has already installed a private clock; we did not re-test that case.
Sources
// The agent clicked "Pay". Did the right things actually happen?
Example from the README
Your agent says it works. Reticle checks the network, state and console inside your running app, and names the line to fix.
85 of 88 seeded bugs caught in the public benchmark