Have you ever seen pip list print a package, then watched import load a completely different file from disk? I spent forty-eight hours on that exact mismatch, and the wheel was never the villain. A folder sitting on sys.path had stolen the name before the installed distribution could speak. This is the lab notebook I wish I had printed at hour zero, not hour forty-seven.
I am writing this as field notes from a local reproduction tree, not as a production war story with invented dashboards. Every command below is meant to be rerun on your machine. If a step is a proposal rather than a command I actually executed in this writeup, I label it that way.
Hour 0: the question that looked too simple
Why would import demo_codec succeed and still point at the wrong file? I had just installed a tiny wheel into a fresh virtualenv, and pip list showed the version I expected. The module even had a __version__ attribute, so my first instinct was to trust it. That instinct is how you lose a weekend.
Python does not import “the package you installed.” It imports the first module object whose name wins on sys.path. The sys.path documentation is blunt about this, and I still ignored it. Do you actually print module.__file__ before you trust a green import, or do you assume the wheel won?
The lab layout that reproduces the trap
I built a disposable tree so the failure would be boring and repeatable. Put this somewhere outside your real repositories, because the whole point is name collision.
shadow_lab/
app.py
demo_codec/
__init__.py
installed_src/
pyproject.toml
demo_codec/
__init__.py
The local folder is the trap. The inner project is the “real” distribution you think you installed. Here is the trap module:
# shadow_lab/demo_codec/__init__.py
ORIGIN = "local-folder"
__version__ = "0.0.0-local"
And here is the distribution you actually meant to import:
# shadow_lab/installed_src/demo_codec/__init__.py
ORIGIN = "installed-wheel"
__version__ = "1.2.3"
# shadow_lab/installed_src/pyproject.toml
[project]
name = "demo-codec"
version = "1.2.3"
description = "Lab package used only to prove import shadowing."
requires-python = ">=3.11"
The app looks harmless. That is the point. It never mentions sys.path and still gets poisoned by the current working directory.
# shadow_lab/app.py
import demo_codec
import pathlib
import sys
print("executable:", sys.executable)
print("cwd:", pathlib.Path.cwd())
print("sys.path[0]:", sys.path[0])
print("module_file:", getattr(demo_codec, "__file__", None))
print("origin:", demo_codec.ORIGIN)
print("version:", demo_codec.__version__)
Hours 1–8: the commands that lied politely
I created the env, installed the inner project, and ran the app from the lab root. The output was consistent, and that consistency was the bug.
cd shadow_lab
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install ./installed_src
python app.py
python -c "import demo_codec, inspect; print(inspect.getfile(demo_codec))"
python -m pip show demo-codec
pip show reported 1.2.3 from site-packages. python app.py still printed local-folder and 0.0.0-local. Why? Because running a script prepends the script directory to sys.path, and that directory contained demo_codec/. The installed wheel never got a vote.
I then tried the form that many tutorials treat as equivalent.
python -m app
That failed for a different reason in this layout, because there is no app package. Running python /absolute/path/to/shadow_lab/app.py from another working directory still prepended the script directory. Changing the shell’s cwd did not save me. Have you been debugging cwd when the real slot is sys.path[0]?
Hours 8–24: what I tried, and what broke
I did the usual thrash before I printed the one table I actually needed.
- Reinstalled the wheel twice, including
--force-reinstall --no-cache-dir. - Printed
python -c "import sys; print(sys.path)"without runningapp.py, which hid the script-directory insertion. - Deleted
__pycache__because stale bytecode is a real problem, just not this problem. - Exported
PYTHONPATHtosite-packages, which still lost tosys.path[0]. - Renamed the local folder to
demo_codec.bakand watched the installed origin appear immediately.
Step 5 was the cheap confirmation. If renaming a directory changes which library you import, you never had a packaging bug. You had a name collision on the import path.
Python 3.11 added -P and PYTHONSAFEPATH so the interpreter can refuse to prepend the script directory and the current working directory. The command-line docs for -P and the PYTHONSAFEPATH env var are the primary sources I should have opened first.
python -P app.py
PYTHONSAFEPATH=1 python app.py
Both of those loaded installed-wheel in this lab. That is the repeatable control, not a benchmark and not a claim about every project on earth. Isolated mode (-I) also avoids this class of surprise, but it disables user site-packages too, so it is a bigger hammer.
The artifact: a receipt script and a decision table
I got tired of eyeballing prints, so I wrote a receipt that fails closed. Save this as shadow_lab/import_receipt.py and run it the same way you run the app.
# shadow_lab/import_receipt.py
from __future__ import annotations
import importlib
import json
import pathlib
import sys
from typing import Any
EXPECTED_ORIGIN = "installed-wheel"
EXPECTED_VERSION = "1.2.3"
def module_receipt(name: str) -> dict[str, Any]:
module = importlib.import_module(name)
file_path = getattr(module, "__file__", None)
resolved = str(pathlib.Path(file_path).resolve()) if file_path else None
return {
"name": name,
"executable": sys.executable,
"sys_path_0": sys.path[0],
"file": resolved,
"origin": getattr(module, "ORIGIN", None),
"version": getattr(module, "__version__", None),
"safepath_flag": bool(getattr(sys.flags, "safe_path", False)),
}
def main() -> int:
receipt = module_receipt("demo_codec")
print(json.dumps(receipt, indent=2))
ok = (
receipt["origin"] == EXPECTED_ORIGIN
and receipt["version"] == EXPECTED_VERSION
and receipt["file"] is not None
and "site-packages" in receipt["file"]
)
if not ok:
print("RECEIPT_FAIL: import did not come from the installed wheel", file=sys.stderr)
return 1
print("RECEIPT_OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run the matrix below and keep the JSON. The point is not speed. The point is a file you can paste into a ticket when someone says “but it imported fine.”
python import_receipt.py
python -P import_receipt.py
PYTHONSAFEPATH=1 python import_receipt.py
python -I import_receipt.py
| How you launch | What sys.path[0] usually becomes |
Who wins in this lab | Receipt |
|---|---|---|---|
python app.py from shadow_lab/
|
the script directory | local folder | RECEIPT_FAIL |
python -P app.py |
no unsafe prepend | installed wheel | RECEIPT_OK |
PYTHONSAFEPATH=1 python app.py |
same as -P
|
installed wheel | RECEIPT_OK |
python -c "import demo_codec" |
empty string / cwd rules | depends on cwd | check __file__
|
python -I import_receipt.py |
isolated path | installed wheel, if visible | usually RECEIPT_OK
|
Proposal, not a claim I measured in CI: add the receipt as a job that launches the app the same way production launches it. A pytest collected with python -m pytest can hide a script-directory trap that python app.py still hits.
Hours 24–48: what I would repeat, and what I would not
I would repeat the rename test first, because it is faster than reading packaging docs while angry. I would repeat printing sys.flags.safe_path, sys.path[0], and module.__file__ as one JSON object. I would not repeat reinstalling the same wheel into the same interpreter while a same-named folder still sits next to the entry script.
Would I send this tree to a second machine? Yes, when my laptop already has leftover PYTHONPATH exports and editable installs. I used MonkeyCode’s free model access and free server option to rerun the same receipt commands in a clean shell, without dumping more junk into my local env. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am not claiming model names, quotas, hardware, or runtime limits here, because I do not have those details to verify.
The useful part of that second pass was social, not magical. A clean shell either reproduces RECEIPT_FAIL or it does not. If it does not, your laptop has extra path state. If it does, stop blaming the wheel.
Limitations, and who should skip this
This lab does not explain namespace packages split across two directories, frozen binaries, zip imports, or custom import hooks. It also does not replace pinning, hashes, or a lockfile. -P can break scripts that intentionally import sibling modules from the script directory, so do not flip it globally without running the receipt.
Skip this approach if your code cannot leave your machine, if you cannot install even a throwaway virtualenv, or if you need a guaranteed hosted runtime. A free server option is still someone else’s computer. Do not paste secrets, private keys, or production dumps into any remote coding environment, including this one.
Skip it if your real bug is two interpreters, because then sys.executable is the receipt you need, not ORIGIN. Skip it if the module has no __file__ because it is built-in or frozen. Print spec.origin from importlib.util.find_spec in that case, and do not pretend a folder collision is the only failure mode.
The checklist I am keeping
- Print
sys.executable,sys.path[0],sys.flags.safe_path, andmodule.__file__together. - Rename the local folder before you reinstall the wheel.
- Launch the receipt the same way the app launches, including
python file.pyversus-m. - Treat
pip showas a statement about a distribution, not about the nextimport. - Use
-PorPYTHONSAFEPATHas a control in the lab, then decide whether production can accept that behavior.
If you run the layout, paste the JSON receipt and the launch line, not a screenshot of pip list. Which slot on sys.path actually won on your machine?
Top comments (0)