Have you ever watched pytest stay green on a laptop and then fail on a machine with a clean site-packages tree? I spent forty-eight hours chasing a version mismatch that was never a version mismatch at all. The installed wheel looked healthy, and the tests looked healthy, which is the most expensive combination I know. They were importing a different tree than the console script, and I kept patching the tree that nothing else would load.
That is not a clever outage story. It is a working-directory story with extra print statements. Working directories lie with a straight face, and they do it in every language that treats the current folder as a package root. Python just happens to make the lie feel official because sys.path[0] looks like configuration instead of coincidence.
The question I should have asked on hour one
Was the failing process importing the same file as the passing tests, or only the same module name? Module names are cheap nicknames. File paths are the actual evidence. I asked pip for a version, then I asked pytest for a vibe, and I treated those two answers as one answer because they used the same letters. Why do we keep doing that after the hundredth time it bites?
I am writing this as field notes from a reproduction, not as a tale with fake production traffic. The layout is small enough to rebuild in a scratch directory. If your day job package is larger, the same disagreement still shows up in __file__.
What I tried while I still trusted pip show
I treated the failure like a packaging bug, because that diagnosis feels adult and bounded. Bounded diagnoses are comforting, and comfort is how you lose a second afternoon. I did not start with the import origin. I started with rituals that look like control.
Here is the ordered list of things I actually ran before I questioned sys.path:
- Reinstalled the package with pip, then reinstalled it again with
--no-cache-dir. - Pinned the same version in
requirements.txtandpyproject.toml, as if the pin were a chaperone. - Deleted
__pycache__directories and*.pycfiles like bytecode had personally lied to me. - Blamed the pytest cache, ran
--cache-clear, and enjoyed a burst of unearned progress. - Printed
mypkg.__version__from two entry points and somehow ignored the disagreement.
That last item should have ended the mystery before dinner. Did I stop? Of course I did not stop. Stale metadata is easier to believe than “Python imported a different folder.” I assumed an egg-info file was confused. Egg-info was fine. I was the confused file.
Commands I kept repeating, as if volume would create insight:
python -m pip show mypkg
python -c "import mypkg; print(mypkg.__version__); print(mypkg.__file__)"
python -m site
pytest -q --cache-clear
The pip metadata pointed at site-packages. The -c one-liner sometimes agreed when I ran it from a random directory. Pytest disagreed whenever I launched it from the repository root. Why did I not print __file__ inside the test module on hour one, before I reinstalled anything?
What actually broke
Pytest, by design, puts the project rootdir on sys.path. A regular python -c import does not reconstruct that path in the same way. A python -m invocation prepends the current working directory. A script invocation prepends the script directory instead of your mental model of “the project.” Those three loaders are not one loader wearing three hats.
So I had a local package directory named mypkg/ sitting at the repo root, plus an installed copy in site-packages. Tests imported the repo tree. A console script imported the wheel. I kept editing tests until the local tree looked healthy. The installed copy stayed broken, which is exactly what a clean interpreter later showed me without raising its voice.
Here is a minimal reproduction. Treat it as a labeled lab setup, not as an unnamed company’s incident report.
Reproduction layout
shadow_demo/
mypkg/
__init__.py
tests/
test_version.py
pyproject.toml
Working-tree mypkg/__init__.py, which pytest will see from the repo root:
# working tree copy — the one pytest will see from repo root
__version__ = "0.0.0-dev"
FEATURE_FLAG = False
def greeting(name: str) -> str:
return f"dev:{name}"
Installed copy, the one a console script should see after a real install:
# site-packages copy — the one a console script should see
__version__ = "1.2.3"
FEATURE_FLAG = True
def greeting(name: str) -> str:
return f"release:{name}"
And the test that stayed green for far too long:
# tests/test_version.py
import mypkg
def test_greeting_prefix():
# This assertion documents the tree you imported, not the tree you shipped.
assert mypkg.greeting("taylor").startswith("dev:")
If you run that from the repo root, pytest is being honest about a dishonest path. The test is not covering the installed artifact. It is covering the folder you are sitting in, which is a very loyal folder.
The 48-hour timeline, compressed
I want this in field-note form, because that is how the hours actually felt. The clock did not care that the fix was one print statement.
-
Hours 0–6: I trusted
pip show. I reinstalled. I stared at version pins like they were stack traces. - Hours 6–14: I blamed pytest cache, leftover bytecode, and an editor buffer that might have failed to save.
- Hours 14–28: I added print debugging to the installed copy, then wondered why the tests never printed it.
-
Hours 28–40: I finally printed
mypkg.__file__from both contexts and felt briefly unwell. - Hours 40–48: I wrote an import auditor and reran the suite under isolated mode and safe-path mode.
Would I call that efficient? No, and I would not dress it up as a method. Would I skip the auditor next time, just to save ten minutes? Also no, because the ten minutes are imaginary.
The artifact: an import auditor you can actually run
This is the part I would keep even if the rest of the notebook caught fire. The script does not need a framework. It prints the facts that arguments about “the package” usually skip, and it prints them as JSON so you can diff two launch styles.
# tools/import_audit.py
"""Print the import story for a list of module names. Run it the way you ship."""
from __future__ import annotations
import importlib
import importlib.util
import json
import sys
from pathlib import Path
def describe(modname: str) -> dict:
spec = importlib.util.find_spec(modname)
payload = {
"module": modname,
"found": spec is not None,
"origin": getattr(spec, "origin", None),
"submodule_search_locations": list(
getattr(spec, "submodule_search_locations", []) or []
),
"already_in_sys_modules": modname in sys.modules,
}
if spec is None:
return payload
try:
module = importlib.import_module(modname)
except Exception as exc: # audit should survive a broken import
payload["import_error"] = repr(exc)
return payload
payload["__file__"] = getattr(module, "__file__", None)
payload["__version__"] = getattr(module, "__version__", None)
payload["sys_path0"] = sys.path[0] if sys.path else None
payload["cwd"] = str(Path.cwd())
payload["executable"] = sys.executable
flags = getattr(sys, "flags", None)
payload["safepath"] = bool(flags and getattr(flags, "safe_path", False))
payload["isolated"] = bool(flags and getattr(flags, "isolated", False))
return payload
def main(names: list[str]) -> None:
report = {
"path_head": sys.path[:8],
"modules": [describe(name) for name in names],
}
print(json.dumps(report, indent=2, default=str))
if __name__ == "__main__":
targets = sys.argv[1:] or ["mypkg"]
main(targets)
Run that auditor the way the process actually starts, not the way your fingers prefer to start it. The whole point is the disagreement between rows.
# 1. pytest context (rootdir on sys.path)
python -m pytest -q
python tools/import_audit.py mypkg
# 2. one-liner context from the repo root, then from $HOME
python -c "import mypkg, json, sys; print(mypkg.__file__); print(sys.path[:5])"
cd && python -c "import mypkg; print(mypkg.__file__)"
# 3. safe-path and isolated context (Python 3.11+)
python -P tools/import_audit.py mypkg
python -I tools/import_audit.py mypkg
# 4. module context, which prepends cwd
python -m tools.import_audit mypkg
I also added a guard test that fails when the imported file still lives inside the repository. It is loud on purpose, because quiet guards become comments.
# tests/test_import_origin.py
from pathlib import Path
import mypkg
REPO_ROOT = Path(__file__).resolve().parents[1]
def test_imported_mypkg_is_not_the_working_tree():
imported = Path(mypkg.__file__).resolve()
try:
imported.relative_to(REPO_ROOT)
except ValueError:
return # imported file is outside the repo — good for a packaging check
raise AssertionError(
f"tests imported the working tree at {imported}, not an installed copy"
)
Should every repository run that guard in the default pytest job? No, and the split matters. Libraries whose tests should import the local tree want the shadow. Applications that ship a wheel do not want it during artifact checks.
Decision table I wish I had taped to the monitor
If two rows disagree about __file__, you do not have a flaky test. You have two programs that share a nickname. I would rather look at this table than argue with a pin file.
| How you start Python | What usually lands at sys.path[0]
|
Shadow risk |
|---|---|---|
python app.py |
Directory containing app.py
|
A local sibling module can win |
python -m app |
Current working directory | Repo-root package folders win |
pytest from repo root |
Pytest rootdir, often the repo | Tests import the tree you are editing |
python -c "import app" |
Empty string or cwd, depending on version and flags | Easy to disagree with pytest |
python -P or PYTHONSAFEPATH=1
|
Cwd is not prepended | Local shadowing is much harder |
python -I |
Isolated: no user site, no PYTHONPATH
|
Closest cheap match to somebody else’s machine |
A few extra checks belong beside that table, because editable installs and user-site files are quiet. I now run them before I reinstall anything, which is a sentence I wish I could mail to hour six.
python -m pip show -f mypkg | sed -n '1,40p'
python -c "import importlib.metadata as m; print(m.distribution('mypkg').locate_file(''))"
echo "PYTHONPATH=$PYTHONPATH"
python -c "import site; print(site.getusersitepackages()); print(site.getsitepackages())"
If pip show and mypkg.__file__ point at different trees, stop talking about versions. Versions are a later chapter. The import graph is the plot.
Where a clean interpreter earned its keep
Local laptops accumulate PYTHONPATH exports, editable installs, leftover conda directories, and shell hooks that prepend extra paths. A clean interpreter does not love you enough to hide that mess, and that coldness is the whole point. I wanted a second machine that had never read my shell profile or my accidental PYTHONPATH.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I used MonkeyCode's free model access and free server option as that second opinion, not as an oracle that could replace find_spec. I pasted the two __file__ values and the head of sys.path, then asked for loaders that could explain the split. The useful output was a short list I could falsify: editable install, pytest rootdir, python -m cwd prepend, and a stray PYTHONPATH. Generated explanations that you do not rerun are fan fiction with syntax highlighting, and I already had enough fiction from my laptop.
Would I skip the clean server and only use python -I locally? Sometimes, because isolated mode is the cheaper first cut. A free remote interpreter still helps when user-site packages on the laptop are part of the lie. I still had to run the auditor. The model did not run it for me, and I would not want it to.
What I would repeat, and what I would not
I would repeat these steps, in this order, the next time two entry points disagree about a package that supposedly has one version:
- Print
__file__,find_spec().origin, andsys.path[:5]from every entry point you actually ship. - Run the same printer under
python -Pand underpython -Iwithout changing anything else. - Run it from a working directory that is not the repository root, preferably from
$HOME. - Only then reinstall, pin, rebuild, or “just clear the cache.”
I would not repeat these, even though they felt like work while the clock was running:
- Debating version pins before I know which file was imported.
- Editing tests until they match the working tree and calling that coverage.
- Asking a model to fix the package without giving it both import origins.
- Treating
pip showas a runtime proof instead of an installer receipt.
Is this more ceremony than a one-file script deserves? Yes. Is it less ceremony than another forty-eight hours? Also yes, and that is the only comparison I trust here.
Limitations, and who should not copy this
Safe-path and isolated mode are blunt instruments, not default pytest flags. They will break scripts that intentionally import siblings from the current directory. They will also surprise namespace-package layouts and ad-hoc plugin folders that were never installed as distributions. If your workflow is a pile of loose modules launched from a USB stick, this auditor will yell at you for being yourself.
Do not treat a remote coding server as your CI matrix. It is one extra interpreter, not a substitute for the platforms you actually ship. Do not paste proprietary code into any remote model if your policy forbids that kind of sharing. This workflow assumes you can share module names, absolute paths, and a redacted traceback without leaking secrets.
Skip the “fail if imported from the repo” guard when you are developing a library whose tests should import the local tree. In that case, test the built artifact in a separate job, maybe with pip install dist/*.whl inside a throwaway environment. Mixing both goals in one pytest invocation is how I burned the first day, and I would not like to donate that day again.
The auditor also cannot see modules loaded by a parent process before your code starts. Prefork servers, plugin hosts, and notebook kernels can freeze a shadowed import before main() runs. If already_in_sys_modules is true in the JSON, you are debugging someone else’s loader, not your current file.
Closing the notebook
The tests were never lying. They were answering a narrower question than the one I thought I had asked out loud. I asked whether the package worked. They answered whether this folder worked when it sat first on sys.path. Those questions only look identical on a laptop that has been gently rotting for a year, with a shell profile that remembers every shortcut you should have deleted.
If you already have a clean interpreter, run the auditor there before you rewrite a pin. If you do not, a free remote server is one way to borrow a machine that has never sourced your .bashrc.
Top comments (0)