Did you ever watch a test fail on a line you already deleted from the tree? I did, and I spent a long weekend refusing to believe the import machinery. A rounding helper under src/billing/totals.py changed, yet the remote pytest job stayed red. The assertion still named quantize_cents, even though rg could not find that symbol anywhere in the checkout.
Was the runner stale, or was I testing a different package than the file I kept editing? These notes cover the forty-eight hours between that question and a boring, fully explained green job. I am recording the commands I ran, the ones that lied, and the diagnostic I now paste first. Nothing here is a benchmark, and I did not keep timing tables that I cannot defend.
What I thought was happening
I blamed almost everything except sys.path, because caches and stale layers felt more dramatic than import order. Docker layers felt likely, and an assistant rewriting the wrong file felt likely too. The remote job collected tests successfully and then failed inside billing.totals with a familiar-looking path. The traceback seemed plausible if you did not read the directory prefix slowly enough.
Have you looked at a path and only recognized the filename at the end? I did that for several hours, which is embarrassing in hindsight. I was so sure I had already adopted the modern src/ layout that I stopped listing the repo root. The extra tree was sitting there the whole time, waiting for pytest to prepend '' onto sys.path.
Here is the layout I believed was "the modern Python way," with one leftover directory I had stopped seeing:
repo/
pyproject.toml
src/
billing/
__init__.py
totals.py
tests/
test_totals.py
billing/
__init__.py
totals.py
That extra billing/ directory was the entire story, not a side quest. I had not deleted the original flat package when I moved code under src/. Pytest, running from the repo root, put the empty string on sys.path and imported the leftover tree first. My editable install was real, and it was also irrelevant to the test process.
Hours 0–8: commands that sounded decisive
I ran the usual confidence rituals before I questioned the import path. Each command returned a green-looking answer that did not name the file pytest had actually imported.
git status --short
git rev-parse HEAD
python -c "import billing; print(billing.__file__)"
pytest tests/test_totals.py -q
git status was clean, and HEAD matched the hosting remote I had just pushed. The python -c line printed a path under src/billing/ because that shell already had pip install -e . applied. Pytest still imported ./billing/totals.py because collection happens in a subprocess with a different path setup than my prompt.
Have you ever mixed an editable install with a leftover top-level package of the same name? The two tools will happily disagree, and both will claim they are correct. I also asked a coding assistant to run the tests on a clean box, hoping a fresh machine would flush whatever ghost I was fighting.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and the free server option as that throwaway box, not as a product review. The model suggested pytest from the repo root, which is exactly what I already did locally. The free server reproduced the same red test, which was useful, and it reproduced the same misleading python -c check, which was not.
Hours 8–20: what actually broke
The break was not flaky rounding math hiding in binary floating point. The break was import precedence inside the pytest process. Pytest's default --import-mode=prepend inserts the parent of the test directory onto sys.path before site-packages. For tests/test_totals.py that parent is the repo root, so a top-level billing/ package shadows the editable install.
I finally printed the module inside the test process, not in my interactive shell. That is the whole method, and I wish I had started there instead of rebuilding images. Why did I trust a REPL that pytest never launches?
# tests/test_totals.py
import billing
import billing.totals as totals
def test_where_am_i():
print("billing file:", billing.__file__)
print("totals file:", totals.__file__)
print("billing path:", list(billing.__path__))
assert False # force pytest to show the captured output
pytest tests/test_totals.py -s -k where_am_i
The printout pointed at ./billing/totals.py, not ./src/billing/totals.py. Once I saw that absolute path, the deleted helper in the traceback stopped being a ghost story. I was patching a tree that the test runner had no reason to load.
A decision table I wish I had drawn on hour one
| Observation | Likely import | What to run next |
|---|---|---|
python -c "import billing; print(billing.__file__)" shows src/, but pytest fails on old code |
pytest prepended the repo root | print __file__ inside a test |
Both billing/ and src/billing/ exist on disk |
leftover tree shadows the install | delete or rename the leftover tree |
Only src/billing/ exists, and tests still miss edits |
a non-editable copy in site-packages | inspect billing.__file__ and sys.path together |
| Collection finds zero tests | wrong cwd or wrong testpaths
|
print pathlib.Path.cwd() from conftest.py
|
| The assistant shell has a venv and your laptop does not | two interpreters, two path graphs | run which python and pytest --version in one command |
I am not going to pretend a single pip query is enough by itself. This week the liar was pytest rewriting sys.path, not the package metadata. Draw the table before you rotate another runner.
The artifact: a thirty-line import receipt
I now keep a module that refuses to stay quiet about where billing came from. It is boring on purpose, because boring receipts beat clever theories after midnight. Drop it next to the real tests and let it fail first.
# tests/import_receipt.py
"""Fail fast when tests import a different tree than the src layout."""
from __future__ import annotations
import pathlib
import sys
import billing
def test_import_receipt():
loaded = pathlib.Path(billing.__file__).resolve()
repo = pathlib.Path(__file__).resolve().parents[1]
src_tree = (repo / "src" / "billing" / "__init__.py").resolve()
leftover = (repo / "billing" / "__init__.py").resolve()
print("sys.executable:", sys.executable)
print("cwd:", pathlib.Path.cwd())
print("loaded:", loaded)
print("sys.path[0:5]:")
for entry in sys.path[:5]:
print(" ", entry)
if leftover.exists() and loaded == leftover:
raise AssertionError(
f"tests imported the leftover tree at {loaded}; "
f"expected the src layout at {src_tree}"
)
if src_tree.exists():
assert loaded == src_tree, (loaded, src_tree)
Run it in one command so nobody splits the environment across two shells:
python -m pytest tests/import_receipt.py tests/test_totals.py -s --import-mode=importlib
--import-mode=importlib stops pytest from prepending the repo root in the way that made this shadowing so easy. It is not magic, and it will not save you if two copies still exist. You just have to work harder to import the wrong tree by accident.
I deleted the leftover billing/ directory after the receipt failed in the obvious way. Then I pinned the layout in pyproject.toml so the next scaffold would not recreate a flat package beside src/:
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--import-mode=importlib"
Leaving pythonpath unset is a choice with consequences you can explain. Some people prefer pythonpath = ["src"] instead of an editable install, and that can be honest too. Pick one source of truth. Do not pick an editable install, a pythonpath entry, and a leftover folder together.
What I would repeat on the next clean server
If I am dropping onto a throwaway box or a fresh CI runner, I now run this sequence before I read any assertion message. I want the receipt before the story.
-
pwdandls -laso I am not sitting inside a nested clone. -
ls billing src/billing 2>/dev/nullso the double tree has to pass my eyes. python -c "import sys; print(sys.executable); print('\n'.join(sys.path[:8]))"python -m pytest tests/import_receipt.py -s- Only then the real suite, with the same executable that printed the receipt.
Would I skip step two because the repo "looks standard"? I did skip it, and that is why this notebook exists. The throwaway box helped because I could not hide behind a laptop pip cache story I had not verified. The assistant helped when I pasted two __file__ lines and asked for a comparison, and it did not help when I asked it to fix the tests without that receipt.
An assistant that cannot see sys.path will happily patch the copy you are not running. Should you still let it run pytest for you? Yes, after you make the import path a test, not a hope.
Limitations, and who should not copy this
This receipt assumes a single package named billing and a classic src/ layout on disk. It will get in your way if you ship namespace packages that are supposed to load from several directories at once. It will also nag you in a monorepo where tests intentionally import a vendored tree sitting at the repo root.
Do not treat --import-mode=importlib as a substitute for deleting the extra package. Do not treat a remote scratch machine as proof that your laptop checkout is clean. Do not treat these first-person notes as a performance claim; I did not time the suite, and I would not trust a timing number I had not measured twice on the same machine.
If your tests must import a locally generated package that is not installed, say so in pytest.ini with an explicit pythonpath and no second copy on disk. Implicit path munging is how I lost a weekend to a helper I had already deleted.
Closing the notebook
The rounding bug was real, and it was already fixed under src/. The suite never loaded that file, so the job could not possibly go green. Once the leftover tree was gone, pytest failed on the new helper, I finished the fix, and the run passed for a boring reason I could print.
Next time a traceback filename looks close enough, I will print __file__ inside the test process before I argue with the code. Close enough is how the wrong tree survives a full weekend of serious-looking commands. What is the first path you print when a remote pytest run disagrees with your editor?
Top comments (0)