Have you ever watched types.SimpleNamespace explode after an assistant politely reorganized your models? I certainly did, and the traceback looked like a typing problem instead of a path problem. These field notes reconstruct a lab session I can rerun, not a company incident dressed up with invented metrics. I wanted every command to be copy-pasteable, so the failure is small, loud, and intentional.
The background that sent me down the wrong hallway
I had asked a coding assistant to pull a handful of TypedDicts out of a fat models.py file. It created a brand new types.py beside main.py, which felt tidy until a worker imported types for SimpleNamespace. Why did a rename that looked so harmless take over the rest of my night? Because Python's import machinery is loyal to sys.path, and sys.path[0] is often the current directory, not the standard library.
The exception did not say you shadowed the stdlib. It said AttributeError: module 'types' has no attribute 'SimpleNamespace'. I spent the first block of time reading typing documentation that had nothing to do with the crash. Have you noticed how assistants double down on the wrong subsystem when the traceback mentions a familiar module name?
Hours 0–8: what I tried first
I treated this like a typing-version mismatch, because that is the story the words wanted me to believe. Here is the ordered list of dead ends I actually walked through before I printed a single module path.
- I pinned
typing_extensionsagain, even thoughSimpleNamespacenever lived there at all. - I printed
sys.versiontwice, hoping a silent interpreter upgrade had dropped the attribute overnight. - I reinstalled the project as an editable extra, as if packaging could restore a stdlib class.
- I asked the assistant to fix the types import, and it rewrote
typingaliases instead of filenames. - I restarted the shell, which cleared nothing except my sense of how late it had become.
None of those steps inspected types.__file__, and that omission is the whole confession I have. If the first print had been the module origin, the next forty hours would have been a rename and a test.
The smallest project that still lies to you
Treat this as a lab layout you can delete afterward, not as a screenshot from some undisclosed production app. Create a throwaway directory and keep it off your real package path so the lesson cannot leak.
shadowlab/
main.py
types.py
worker.py
diagnose_shadows.py
test_no_stdlib_shadows.py
types.py is the trap the assistant left behind, and it even looks like good taste:
"""Project-local type aliases. Looks innocent. Shadows the stdlib."""
from typing import TypedDict
class UserRecord(TypedDict):
user_id: str
email: str
worker.py is the code I believed was unrelated, which is exactly why it survived review:
import types
def wrap(payload: dict) -> types.SimpleNamespace:
return types.SimpleNamespace(**payload)
if __name__ == "__main__":
print(wrap({"ok": True}))
main.py only exists to prove the happy path still looks fine, which is the cruel part:
from types import UserRecord
def demo() -> UserRecord:
return {"user_id": "u1", "email": "dev@example.com"}
if __name__ == "__main__":
print(demo())
Run the worker from that directory and watch the wrong module win the name types:
cd shadowlab
python worker.py
You should see an AttributeError about SimpleNamespace, and then you should resist the urge to open typing docs. Run the one print I should have started with, because module identity beats folklore every time I forget it:
python -c "import types; print(types); print(getattr(types, '__file__', None))"
Does that path point at your project file instead of the standard library tree? That single line is the entire bug, wearing a costume that looks like a typing incident.
Hours 8–24: what actually broke
Three separate mechanisms stacked, and I kept debugging them as if they were one story. First, sys.path[0] for a script run is the script's directory, so a local types.py outranks the stdlib types module. Second, python -c uses an empty string for sys.path[0], which still means the current working directory, so snippets lie in the same way. Third, assistants love the filename types.py because humans say put the types over there, and the model does not consult sys.stdlib_module_names.
I also learned that from types import UserRecord succeeding is not evidence of health in the tree. A shadow module can export the names you added while hiding the names you still need from the real stdlib. Have you ever trusted a green import as proof the module identity was correct? I had, and the worker paid for that shortcut.
The diagnostic I should have run on hour one
Treat the script below as the artifact, not as folklore from a memory of production. It compares each imported file against CPython's stdlib directory from sysconfig, which is less theatrical than guessing from the word lib in a path.
# diagnose_shadows.py
from __future__ import annotations
import sys
import sysconfig
from pathlib import Path
SUSPECTS = [
"types",
"code",
"token",
"copy",
"email",
"json",
"logging",
"random",
"parser",
"profile",
"locale",
"stat",
]
STDLIB = Path(sysconfig.get_path("stdlib")).resolve()
def origin(mod) -> str:
return getattr(mod, "__file__", "<builtin-or-namespace>")
def is_outside_stdlib(mod) -> bool:
file = getattr(mod, "__file__", None)
if not file:
return False
path = Path(file).resolve()
try:
path.relative_to(STDLIB)
except ValueError:
return True
return False
def main() -> int:
print("executable:", sys.executable)
print("path[0] :", sys.path[0] if sys.path else "<empty path>")
print("safe_path :", bool(getattr(sys.flags, "safe_path", False)))
print("stdlib :", STDLIB)
failed = 0
stdlib_names = set(getattr(sys, "stdlib_module_names", ()))
for name in SUSPECTS:
if name not in stdlib_names:
continue
__import__(name)
mod = sys.modules[name]
path = origin(mod)
shadowed = is_outside_stdlib(mod)
marker = "SHADOW?" if shadowed else "ok"
if shadowed:
failed += 1
print(f"{marker:8} {name:12} -> {path}")
import types as loaded_types
print("types.SimpleNamespace:", hasattr(loaded_types, "SimpleNamespace"))
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
Run it twice: once inside shadowlab, and once from a parent directory that does not contain types.py. The delta between those two dumps is the lesson, and it is cheaper than another theory about typing plugins.
python diagnose_shadows.py
python -P diagnose_shadows.py
-P (and PYTHONSAFEPATH) stops Python from prepending the script directory onto sys.path. If the error vanishes only under -P, you do not have a typing bug. You have a path bug that an assistant cannot see from the traceback text alone.
A recovery snippet if you cannot rename yet
Sometimes a branch is frozen and you only need the real stdlib module for one worker. This is a stopgap, not a naming strategy, and I would still rename the file before merge.
import importlib.util
import sys
import sysconfig
from pathlib import Path
stdlib_types_path = Path(sysconfig.get_path("stdlib")) / "types.py"
spec = importlib.util.spec_from_file_location("_stdlib_types", stdlib_types_path)
stdlib_types = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stdlib_types)
print(stdlib_types.SimpleNamespace(ok=True))
print("shadow still in sys.modules:", sys.modules["types"].__file__)
Notice that sys.modules["types"] can remain the shadow while you hold a second object. That split brain is why I do not leave this pattern in application code.
A decision table I wish I had printed on hour one
| Observation | Do not do this | Do this instead |
|---|---|---|
AttributeError on a stdlib attribute |
Upgrade random typing packages | Print module.__file__ immediately |
Assistant creates types.py or code.py
|
Keep the tidy name | Rename to type_defs.py or app_types.py
|
| Snippet works in a REPL but fails as a script | Blame pytest plugins | Compare sys.path[0] in both contexts |
python -c and python worker.py disagree |
Add more try/except | Dump sys.path and sys.stdlib_module_names
|
| Tests pass after a rewrite of imports | Ship it | Search the tree for files named like stdlib modules |
Hours 24–48: what I would repeat
I would start with module identity, not with framework folklore about typing versions. I would also keep a second, boring interpreter around so my laptop's PYTHONPATH cannot gaslight me during a rename. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft the suspect-filename list, then reran diagnose_shadows.py on its free server option so a clean tree could contradict my local sys.path.
Would I let the assistant pick new filenames without a denylist sitting in the repo? Not again, and I would not accept a patch that only silences the traceback. The repeatable ritual now looks like this, and it fits on one card.
- Generate or accept the patch in a branch, never straight onto the only checkout you own.
- Run
diagnose_shadows.pyin the project root and again in an empty directory. - Fail the build if any stdlib name resolves to a path inside the project tree.
- Rename the file before you rewrite a single import to silence the traceback.
- Re-run the original worker, not a reduced snippet that never imported
types.
A tiny pytest guard belongs in the same field kit. It does not need a network, and it does not need a story about who employs me.
# test_no_stdlib_shadows.py
from pathlib import Path
import sysconfig
ROOT = Path(__file__).resolve().parent
DANGEROUS = {
"types.py",
"code.py",
"token.py",
"copy.py",
"email.py",
"json.py",
"logging.py",
"random.py",
"parser.py",
"profile.py",
}
STDLIB = Path(sysconfig.get_path("stdlib")).resolve()
def test_repo_does_not_shadow_stdlib_filenames():
hits = sorted(p.name for p in ROOT.glob("*.py") if p.name in DANGEROUS)
assert hits == [], f"stdlib-shadowing filenames: {hits}"
def test_types_is_the_real_stdlib():
import types
assert hasattr(types, "SimpleNamespace")
path = Path(types.__file__).resolve()
path.relative_to(STDLIB)
If you want a one-line grep while the test file is still unwritten, this pair is unkind enough to future you:
git ls-files '*.py' | awk -F/ '{print $NF}' | sort | uniq -c | sort -nr
python -c "import sys; print('\n'.join(sorted(sys.stdlib_module_names)))"
The first command shows colliding basenames in the tree you actually ship. The second reminds you which names were never yours to take, even when an assistant offers a tidy extraction.
Limitations, because this workflow is not a personality
Printing __file__ will not save you from a C extension that never set __file__ in the first place. It will not explain namespace packages that span two directories, and it will not catch a shadow that only appears inside a frozen zipapp. -P can break scripts that legitimately import sibling modules from the script directory, so do not sprinkle it into every shebang as a superstition.
Who should not use this approach as written?
- Anyone who cannot run untrusted generated files in an isolated directory should not paste assistant output onto a machine that holds secrets.
- Teams that already vendor a module literally named
typesfor historical reasons need a migration plan, not a sarcastic pytest assertion. - If your runtime is not CPython,
sys.stdlib_module_namesand-Pmay not mean what these notes assume about path order. - A second interpreter is a comparison tool, not proof that production import order will match your laptop or a free remote shell.
I am also not claiming the assistant is bad at coding in some general way that needs a score. The failure mode is more boring than that debate: it optimized for a tidy name, and Python optimized for the first file it found.
What I keep on the card now
If a stdlib attribute goes missing after a refactor, I do not open the typing docs first. I print the module path, I compare two working directories, and I search for a filename that was never mine to claim. Have you checked whether your newest helper file is also a stdlib module name sitting at the project root? The forty-eight hours were not about SimpleNamespace at all. They were about believing a filename that sounded like documentation.
Top comments (0)