DEV Community

Taylor Wang
Taylor Wang

Posted on

Field Notes: 48 Hours of Green Tests, One Clean Interpreter, and a Shadowed Import

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:

  1. Reinstalled the package with pip, then reinstalled it again with --no-cache-dir.
  2. Pinned the same version in requirements.txt and pyproject.toml, as if the pin were a chaperone.
  3. Deleted __pycache__ directories and *.pyc files like bytecode had personally lied to me.
  4. Blamed the pytest cache, ran --cache-clear, and enjoyed a burst of unearned progress.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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:")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    )
Enter fullscreen mode Exit fullscreen mode

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())"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Print __file__, find_spec().origin, and sys.path[:5] from every entry point you actually ship.
  2. Run the same printer under python -P and under python -I without changing anything else.
  3. Run it from a working directory that is not the repository root, preferably from $HOME.
  4. 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 show as 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)