DEV Community

Taylor Wang
Taylor Wang

Posted on

I Blamed Missing Dependencies for 48 Hours. PYTHONPATH Was Smuggling the Package.

Have you ever watched a Python import succeed on your laptop and explode the moment someone else clones the repo? I spent forty-eight hours on that exact split-brain, and the package was never really installed. Locally I could import inventory_sync from any directory I opened, so every unit test looked stubbornly green. On a clean shell the same module name raised ImportError, and I kept adding dependencies that were never the problem.

This is a field notebook, not a victory lap. I will show what I tried, what actually broke, and the tiny repro I now run before I trust a green bar. If you already live in src layouts and editable installs, you still might want the decision table near the end.

Hour 0–8: I treated ImportError like a requirements bug

The failure looked like a missing extra. A worker script did from inventory_sync.jobs import nightly_rollup, and a fresh clone on another machine raised ModuleNotFoundError: No module named 'inventory_sync'. I froze my local environment, compared hashes, and reinstalled every pin twice. Nothing moved, which should have been the first alarm.

I asked the usual questions out loud. Did pip lie? Did a transitive package steal the name? Was the Python on PATH a different minor version than the one pytest used? I even created a throwaway venv, installed the lockfile, and ran the worker again. Locally it still imported. Remotely it still died.

Here is the command sequence I kept repeating like a superstition:

python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.lock
python -c "import inventory_sync, sys; print(inventory_sync.__file__); print(sys.path)"
Enter fullscreen mode Exit fullscreen mode

On my laptop the printout pointed at a source tree I recognized. I nodded, closed the terminal, and wasted another evening reading packaging docs I already knew.

Hour 8–24: the green import had a hometown

Have you ever printed sys.path and still refused to believe it? I finally dumped the path as JSON so I could diff two machines without squinting. The local list started with an absolute directory I had exported months ago for a one-off notebook. That directory contained a nested copy of inventory_sync, complete with an empty __init__.py and a half-finished jobs module.

The remote shell had no such gift. It only had the venv site-packages and the current working directory, which was a checkout that used a src/ layout and never installed the project itself. So the laptop was not proving the package. It was proving that PYTHONPATH could smuggle a folder with the right name.

# repro_path_dump.py — labeled example, run this on each machine
import json
import os
import sys

def main() -> None:
    payload = {
        "executable": sys.executable,
        "cwd": os.getcwd(),
        "pythonpath_env": os.environ.get("PYTHONPATH"),
        "sys_path": sys.path,
    }
    print(json.dumps(payload, indent=2))

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Once I saw the extra prefix, the rest of the night was ugly and simple. I unset PYTHONPATH, opened a new terminal, and the local import failed the same way as the clean machine. Why did I trust a shell profile more than a lockfile?

Hour 24–48: I needed a machine that did not inherit my dotfiles

Unsetting a variable on my own laptop is not evidence. The next morning I wanted a shell that had never read ~/.zshrc, never seen my direnv hooks, and never inherited a user-level sitecustomize.py. That is when a free remote runner actually earned a place in the notes.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am writing this as product outreach, not as an independent benchmark lab. I used MonkeyCode's free model access to draft a minimal project skeleton from the failure, and I used the free server option as a second Python that did not inherit my laptop PATH. I am not going to name models, quote quotas, or pretend I measured latency. The useful part was environmental isolation, not a magic autocomplete.

The generated skeleton was ordinary on purpose. One package under src/, one console script, one test that imports the public module, and a Makefile target that installs the project the way a stranger would.

inventory-sync/
  pyproject.toml
  src/inventory_sync/__init__.py
  src/inventory_sync/jobs.py
  tests/test_import_surface.py
  repro_path_dump.py
Enter fullscreen mode Exit fullscreen mode
# pyproject.toml excerpt — example layout, not a published package
[project]
name = "inventory-sync"
version = "0.0.0"
requires-python = ">=3.11"

[project.scripts]
nightly-rollup = "inventory_sync.jobs:main"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]
Enter fullscreen mode Exit fullscreen mode
# tests/test_import_surface.py
import inventory_sync
from inventory_sync.jobs import nightly_rollup

def test_package_comes_from_site_packages() -> None:
    path = inventory_sync.__file__ or ""
    assert "site-packages" in path or "dist-packages" in path

def test_job_entrypoint_is_callable() -> None:
    assert callable(nightly_rollup)
Enter fullscreen mode Exit fullscreen mode

On the clean server I ran the install the way a CI image should, not the way my laptop had been cheating:

unset PYTHONPATH
python -m venv /tmp/clean
/tmp/clean/bin/python -m pip install -U pip
/tmp/clean/bin/python -m pip install .
/tmp/clean/bin/python -m pytest tests/test_import_surface.py -q
/tmp/clean/bin/nightly-rollup --help
Enter fullscreen mode Exit fullscreen mode

The first attempt failed, which was the point. pip install . without a build backend that understood src/ had been leaving the module off sys.path unless I sat in the repository root. After the pyproject.toml find configuration was honest, the import survived a change of working directory. That is the only pass I now accept.

The artifact I keep: a four-row decision table

I do not want another forty-eight hour loop the next time a teammate says it works on their machine. This table is the whole method.

Observation Likely lie Command that falsifies it What I do next
import pkg works in any directory PYTHONPATH, cwd on sys.path, or a leftover .pth file python -c "import pkg,sys; print(pkg.__file__); print(sys.path[:5])" after unset PYTHONPATH Delete the smuggled directory from the env and reinstall
Tests pass, console script missing Editable install never created scripts, or a different env owns PATH which nightly-rollup then head -1 $(which nightly-rollup) Reinstall with pip install . and confirm the shebang
CI fails, laptop passes User site-packages, direnv, or a profile export Same import inside env -i PATH="$PATH" HOME="$HOME" python Move the check onto a clean runner
Import works only from repo root Project was never installed; you are importing a folder cd /tmp && python -c "import inventory_sync" Fix src/ packaging and install the project as a project

If you only steal one row, steal the last one. Changing directory is cheaper than reading packaging essays.

What broke, in plain language

Three separate lies stacked. My shell exported PYTHONPATH so notebooks could see a prototype. The repository used a src layout, so a naive python worker.py from the wrong folder never saw the package. Pip on my laptop had been installing extras while the actual project stayed uninstalled, because the import already succeeded. Each lie made the next one look reasonable. Together they produced a green laptop and a red clone.

I also learned that generated helper scripts are eager to paper over this. A model will happily suggest sys.path.append(...) or a relative import tweak that keeps the laptop green. That patch is how the smuggling survives code review. The clean server refused the patch, which is why I still want a second machine even when the first one has perfect autocomplete.

What I would repeat

I would still start with a path dump, because arguments about versions waste hours when the module file is simply the wrong file. I would still keep a tiny import test that asserts site-packages appears in __file__, even though it looks petty in a pull request. I would still run that test on a shell that never read my profile.

The repeatable checklist is short enough to paste:

  1. unset PYTHONPATH and open a new terminal, not a subshell you already polluted.
  2. Create a fresh venv, then pip install . from a copy of the repo, not pip install -e . first.
  3. cd /tmp and import the public package with that venv's interpreter.
  4. Run the console script by PATH, not by python -m from the repo root.
  5. Only then freeze dependencies and talk about missing extras.

Would I skip the clean server if the path dump already showed the smuggled directory? Probably, for a solo script. For anything with an entry point, I still want the second machine, because entry points are where local PATH keeps lying.

Limitations, and who should not copy this

This workflow assumes you can install the project as a project. If you ship a loose collection of notebooks, a src layout and a console script test will not save you. It also assumes a POSIX-ish shell; Windows developers should translate unset and env -i instead of pretending the same one-liners apply. Namespace packages, optional extras, and native extensions can fail for reasons this notebook never touched.

Do not use a free shared runner if the repo contains secrets, customer data, or proprietary models you are not allowed to upload. Do not treat a free server as a compliance boundary, a performance lab, or a substitute for your real CI image. I did not collect timings, I did not compare vendors, and I will not claim this isolation lasts forever. If your team already has ephemeral CI with a locked image, that is the better clean room. I reached for a free remote shell because my laptop had become an unreliable narrator.

Also skip this if your actual bug is a missing system library, a wrong glibc, or a compiled wheel. ImportError can mean those things too, and a path dump will not diagnose an .so that never built.

Closing notes from hour 48

The package was not missing. It was being impersonated by a folder my shell kept introducing to Python. Once a clean interpreter had to install the project like a stranger, the src layout either worked or it did not, and the lockfile stopped being a scapegoat. That is a boring ending, which is how I know it is the right one.

If you want a second Python that does not inherit your dotfiles, a free remote runner is enough to catch this class of lie. I will keep the path dump and the /tmp import. Everything else was noise.

Top comments (0)