Have you ever shipped a refactor that looked smaller than a coffee run and then spent two days proving Python can lie? I asked a free model to lift a handful of dataclasses out of a growing worker module. It did the extraction cleanly enough, except it named the new file types.py, which is a loaded word in this language. Local pytest stayed green. The same tree on a free server started failing on an import that I did not even touch.
I was comparing those two environments with MonkeyCode because free model access and a free server option let me rerun the same commands without inventing a second laptop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The product is not the bug. The bug is that import types is a sentence Python will happily interpret in two incompatible ways.
What I thought was happening
The first error on the server mentioned SimpleNamespace, which I had not edited. So I blamed packaging, then pinned typing_extensions, then reinstalled the project in a fresh virtualenv. Does that sound familiar? You chase the noisy name in the traceback and ignore the quiet name sitting in sys.path[0].
I also blamed the model for “hallucinating an API,” which was unfair in a specific way. types.SimpleNamespace is real. The server was not missing the standard library. It was importing my types.py first, and that file had dataclasses and no SimpleNamespace at all.
Hours 0–8: I debugged the wrong layer
I started with the boring story every remote failure invites. Was the interpreter different? Was pytest collecting a different tree? Had an __init__.py gone missing in the copy step?
What I actually ran, reconstructed here as a lab you can repeat:
# local, from the repo root
python -c "import sys, types; print(sys.executable); print(types.__file__)"
pytest -q
# later, on the free server, from a different working directory
cd app
python worker.py
The local command printed a types.py under the standard library, and the suite passed. The server command printed a types.py sitting next to worker.py. Same repo. Same commit. Different current working directory, so sys.path[0] changed meaning.
I wasted those first hours on dependency noise. The model had also rewritten a couple of imports to the short form from types import SimpleNamespace, which is correct English and a trap the moment a local file steals the name.
Hours 8–24: the “fix” made the lie more stable
I asked the free model how to make the import deterministic. It suggested sys.path.insert(0, ...) in worker.py, which is the kind of one-liner that feels like control. I did not paste it into production code. I did paste it into a scratch file to see what it would do.
It “fixed” the server by forcing the application package onto the front of the path. It also made import types resolve to my helper module in both environments, so pytest finally failed locally too. That was the first honest result of the whole incident. Have you noticed how a bad path mutation can look like progress because it equalizes the wrong outcome?
What broke when I tried that path insert:
-
pyteststarted importingapp.typeswhen a fixture still expected the stdlib module. - A debug snippet using
types.MappingProxyTyperaisedAttributeError. -
python -m pytestandpytestdisagreed, because one of them changedsys.path[0]relative to the test files.
I reverted the insert. The honest fix is rename the helper, not win a fight with the import system.
Hours 24–48: I wrote a detector instead of another prompt
The useful artifact was not another generated patch. It was a tiny receipt I now run in the same working directory as the failing command. If the printed path is inside your repo, you do not have the standard library module you think you have.
# save as tools/which_module.py
# reconstructed lab script, not a captured production log
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
DANGEROUS_NAMES = (
"types",
"code",
"email",
"json",
"logging",
"test",
"token",
"copy",
"parser",
"random",
"site",
"stat",
"string",
"typeshed",
)
def describe(name: str) -> dict[str, str]:
module = importlib.import_module(name)
file_path = Path(getattr(module, "__file__", "") or "")
cwd = Path.cwd().resolve()
try:
relative = str(file_path.resolve().relative_to(cwd))
location = "inside_cwd"
except ValueError:
relative = str(file_path)
location = "outside_cwd"
return {
"name": name,
"file": relative,
"location": location,
"cwd": str(cwd),
"sys_path_0": sys.path[0],
"executable": sys.executable,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("names", nargs="*", default=list(DANGEROUS_NAMES))
args = parser.parse_args()
rows = [describe(name) for name in args.names]
shadowed = [row for row in rows if row["location"] == "inside_cwd"]
for row in rows:
mark = "SHADOW" if row["location"] == "inside_cwd" else "ok"
print(f"{mark:6} {row['name']:10} {row['file']}")
print(f"cwd={rows[0]['cwd']}")
print(f"sys.path[0]={rows[0]['sys_path_0']}")
print(f"executable={rows[0]['executable']}")
return 1 if shadowed else 0
if __name__ == "__main__":
raise SystemExit(main())
Run it twice, because one passing environment is how this class of bug hides:
python tools/which_module.py types code email json logging test
echo "exit=$? cwd=$(pwd)"
cd app
python ../tools/which_module.py types
python -c "import types, inspect; print(types.__file__); print('SimpleNamespace' in dir(types))"
If the first command prints ok and the second prints SHADOW, you are not looking at flaky tests. You are looking at two different modules that share a four-letter name.
A minimal tree that reproduces the lie
This is a reconstructed fixture, not a claim about a private codebase. Drop it in an empty folder and you can watch the split happen without any model in the loop.
proj/
app/
worker.py
types.py
tests/
test_worker.py
tools/
which_module.py
# app/types.py
from dataclasses import dataclass
@dataclass(frozen=True)
class JobSpec:
name: str
timeout_s: int
# app/worker.py
from types import SimpleNamespace
def build_defaults() -> SimpleNamespace:
return SimpleNamespace(retries=2, backoff_s=1.5)
if __name__ == "__main__":
print(build_defaults())
# tests/test_worker.py
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "app"))
from worker import build_defaults
def test_defaults_have_retries():
assert build_defaults().retries == 2
Now run the two entry points:
cd proj
pytest -q # often green: tests may still find stdlib types
cd app && python worker.py # ImportError: cannot import name SimpleNamespace
Why the split? Because script execution puts the script’s directory at sys.path[0]. A test runner started from the repo root may not. Free models love short stdlib imports. They also love extracting “types” into types.py. Those two habits collide as soon as cwd changes, which a free server will do the moment your start command is not the command you run at your desk.
Decision table I wish I had at hour one
| Symptom you see | Do not start with | Check first |
|---|---|---|
ImportError: cannot import name SimpleNamespace from types
|
reinstalling pytest |
python -c "import types; print(types.__file__)" in the failing cwd |
AttributeError: MappingProxyType after a “harmless” extract |
pinning typing stacks | a repo file named types.py or types/__init__.py
|
| CLI fails, pytest passes | blaming collection count |
sys.path[0] under python worker.py versus pytest
|
| pytest fails, CLI passes | blaming the server image | a sys.path.insert the model added “to be safe” |
| Only the free server fails | assuming a missing OS package | print cwd, argv0, and types.__file__ in the job itself |
Failures flip when you cd
|
network, rate limits, “the model drifted” | directory layout plus script versus -m execution |
Keep the table next to the detector. The point is to spend ten minutes on identity of the module before you spend ten hours on identity of the environment.
What I would repeat
I would still use a free model to do the mechanical extract. The extract was fine. The filename was not. I would still run the same tree on a free server, because laptop pytest is a terrible witness for sys.path[0].
What I would repeat, as a checklist rather than a vibe:
- Ban helper filenames that match stdlib modules before the patch lands.
- Print
module.__file__, cwd, andsys.path[0]inside the failing job, not in a later shell. - Prefer
python -m app.workerfrom the repo root overcd app && python worker.py. - Treat
sys.path.insertsuggestions as a smell, even when they make one environment go green. - Rename first. Reinstall second. Prompt third.
Would I ask the model to name files again? Yes, and I would paste the dangerous-name tuple into the prompt. Models are good at moving dataclasses. They are not good at remembering that types, code, email, and test already belong to the language.
Limitations, and who should skip this
This workflow is for import identity bugs, not for missing binaries, not for drifting model output, and not for tests that collected the wrong file. If your failure is an exit code from a command that is not on PATH, a module __file__ print will not save you.
Do not use a free server for secrets, production data, or credentials just to get a second cwd. Do not treat free model access as a pinned compiler: it can rename files, invent path inserts, and still sound confident. Do not run the detector as proof of a compromised machine; a shadowed stdlib name is usually an embarrassing filename, not an incident.
Skip the approach if you cannot execute the same argv in both places. Skip it if your packaging already forbids loose scripts and you only ever run python -m. Skip it if the traceback is inside C extensions or a binary wheel, because __file__ on types will look innocent while a different module is crashing.
The 48 hours were not a model failure in the cinematic sense. They were a working-directory failure wearing a generated filename. After the rename from types.py to job_types.py, both environments imported the same universe again. If you are about to extract helpers, ask one rude question first: what happens to import types when the server starts one directory to the left?
Top comments (0)