20Inside import — sys.path, finders, loaders, and .pyc
In Chapter 19 we met import from the outside — four honest verbs, find, compile, exec, cache, named but still half-trusted. In this one we pry the lid off and watch each one run. Here's the plan. We print sys.path, the ordered list of folders import walks. We watch a stray random.py in your own directory beat the entire standard library. Then we follow the winning file through the finder/loader handshake, into the .pyc bytecode cache with its sixteen-byte header, and out the far side as a live module object. The whole way through, we keep asking the one thing that matters — when you type import X, what exactly decides which bytes on disk become the module you get back? By the end, import is no longer a keyword you trust but a machine you can single-step. It is an ordered search you can print, a finder/loader protocol you could implement yourself, and a compile pipeline that turns the text you type into the same bytecode the Volume 1 VM already knows how to run. It is the same story as every boundary in this volume. What crosses from disk into your process is always bytes, and import is the machine that rebuilds live objects on the near side.
01sys.path: an ordered search, first match wins
Let's start with the bug everyone hits. You're debugging something fiddly with randomness, so you make a throwaway file to poke at it and name it, reasonably, random.py. Run your real program and watch it detonate: AttributeError: module 'random' has no attribute 'random'. You did not touch the standard library. You did not mistype. And yet import random has handed you your file instead of Python's. The very same code that works on your laptop will explode the moment it lands in a directory that happens not to contain your little file. Every Python programmer meets this bug, stares at it for an hour, and walks away convinced import is haunted. It is not. Import is a deterministic walk down a list you can print, and the instant you can see the list, the haunting stops for good.
Here is the whole secret in one sentence. import X does not search your disk and it does not know where the standard library lives. It walks sys.path, a plain list[str] of directory names, from top to bottom. It asks each location the same blunt question, "do you have X?", and it stops at the first one that says yes. First match wins. That rule is the entire mystery. So print the list and look at the thing that has been deciding your imports all along:
>>> import sys
>>> for i, p in enumerate(sys.path):
... print(i, repr(p))
0 '' # cwd / the script's own folder — checked FIRST
1 'C:\\Python312\\python312.zip'
2 'C:\\Python312\\Lib' # the standard library lives here
3 'C:\\Python312'
4 'C:\\Python312\\Lib\\site-packages' # where pip installs landRead the entries in the order the interpreter built them at startup, because the order is the rule. Index 0 is the special one. Run python foo.py and it becomes the folder that foo.py lives in. Run -c, or -m, or the bare REPL and it becomes '', meaning the current working directory. That single entry, checked before everything else, is exactly why a sibling random.py beats the standard library — your project folder is first in line. After it come the $PYTHONPATH entries you set in the environment. Then come the standard-library directories and the frozen modules baked into the executable. And finally the site-packages directory that the site module appends, where every pip install deposits its wares. Top to bottom, first match wins, no exceptions.
sys.pathA list of directory names, rebuilt at interpreter startup. Import re-reads it on every single call, so an edit takes effect immediately.sys.path[0]Chapter 19's rule, now visible: python app/play.py puts app first, python -m app.play puts the current directory first. That one row decides what you can import.$PYTHONPATHHonest, and invisible. It is a value in your shell that your code depends on and your repository does not record, so it works on your laptop and nowhere else.site-packagesLast in line, which is exactly why a file of yours can beat a package you installed. pip install never moves anything ahead of your folder..insert(0, …)Puts your folder ahead of the standard library — the same position that causes shadowing. Prefer .append unless you mean to override.sys.path.append("lib") is a relative entry resolved against the working directory. It works from one folder and dies from another; the anchored form does not.you type
# ---------- wheres_my_import.py ----------
# wheres_my_import.py -- print the ordered list that decides every import
import os
import sys
import sysconfig
STDLIB = sysconfig.get_paths()["stdlib"]
def label(i, entry):
if i == 0:
return "the launched script's folder (or the cwd)"
if entry.endswith(".zip"):
return "the standard library, zipped"
if entry == STDLIB:
return "the standard library"
if entry.endswith("site-packages"):
return "site-packages -- every pip install lands here"
if entry == sys.prefix:
return "this environment's own folder"
if entry == sys.base_prefix or entry.endswith("DLLs"):
return "the interpreter this one was built from"
return "an extra entry (PYTHONPATH, or a .pth file)"
print("python", sys.version.split()[0], "| entries:", len(sys.path))
for i, entry in enumerate(sys.path):
print(f"{i} {os.path.basename(entry) or '(cwd)':<14} {label(i, entry)}")
# ---------- the terminal ----------
$ python wheres_my_import.py
$ python -c "import beat"
$ set PYTHONPATH=C:\tmp\pathbox\tools
$ python -c "import sys, beat; print('BPM', beat.BPM); print('sys.path[1]', sys.path[1])"you see
$ python wheres_my_import.py
python 3.12.7 | entries: 7
0 shop the launched script's folder (or the cwd)
1 python312.zip the standard library, zipped
2 DLLs the interpreter this one was built from
3 Lib the standard library
4 anaconda3 the interpreter this one was built from
5 .venv this environment's own folder
6 site-packages site-packages -- every pip install lands here
$ python -c "import beat"
ModuleNotFoundError: No module named 'beat'
$ set PYTHONPATH=C:\tmp\pathbox\tools
$ python -c "import sys, beat; ..."
BPM 128
sys.path[1] C:\tmp\pathbox\tools- Your list will not look like this one. The entries depend on your install; the order never varies.
- Editing
sys.pathchanges this process only. Nothing is written anywhere, and the nextpythonstarts from a clean list. - A
.pthfile insite-packagescan append entries you never wrote. If a folder appears from nowhere, that is almost always why. PYTHONPATHis split onos.pathsep— a semicolon on Windows, a colon everywhere else. Getting that wrong silently adds one nonsense entry.sys.path.append("lib")looks harmless and is the entry that follows you around: run the same script from one folder up and the import dies.- There is no
sys.path.removeritual to learn. It is a list;del sys.path[2]works, and is just as permanent as everything else here (not at all).
AttributeErrorBecause import worked perfectly. It found a file called random.py, ran it, and handed it to you. Your file simply has no seed in it.random.__file__The single most useful line in this chapter. It ends the argument: it prints the exact path of the module you actually got.sys.stdlib_module_namesA frozenset of every name the standard library owns. Check a filename against it before you save, and you never hit this bug again.__init__.py. An empty look-alike directory is recorded as a namespace portion and the scan continues, so the real package still wins.pip install cannot helpsite-packages sits below your folder on the list. Installing the real thing puts a second copy further down, where it will never be reached.coinflip.py, my_random.py — and the very next import falls through to the standard library.you type
# ---------- your folder ----------
jukebox/
random.py # the five-minute experiment you meant to delete
shuffle_demo.py # the program that suddenly broke
# ---------- random.py (yours) ----------
# random.py -- a five-minute experiment you meant to delete
COIN = ["heads", "tails"]
# ---------- shuffle_demo.py ----------
# shuffle_demo.py -- pick a track at random
import random
TRACKS = ["Blinding Lights", "Titanium", "Levels"]
random.seed(20)
print(random.choice(TRACKS))
# ---------- the terminal ----------
$ python shuffle_demo.py
$ python -c "import random; print(random.__file__)"
$ mv random.py coinflip.py # ren random.py coinflip.py on Windows cmd
$ python shuffle_demo.pyyou see
$ python shuffle_demo.py
Traceback (most recent call last):
File "C:\tmp\jukebox\shuffle_demo.py", line 5, in <module>
random.seed(20)
^^^^^^^^^^^
AttributeError: module 'random' has no attribute 'seed'
$ python -c "import random; print(random.__file__)"
C:\tmp\jukebox\random.py
$ mv random.py coinflip.py
$ python shuffle_demo.py
Levels- Renaming the
.pyis enough. The orphaned__pycache__/random.cpython-312.pycleft behind is never importable on its own once its source is gone. - The bug travels badly: your teammate's checkout has no
random.py, so the code works for her and breaks only for you — or exactly the reverse. types.py,string.py,code.py,copy.py,queue.py,select.pyandsignal.pyare the quiet ones. They all shadow, and none of them sounds dangerous.- A shadow can hide for weeks. It only detonates when something — often a library you installed, not your own code — asks for the part your file does not have.
- The name you must not use is the import name, not the pip name. A file called
yaml.pyshadowspyyaml, whose distribution name never appears in your code. - Renaming a shadowing file inside a running REPL changes nothing. The wrong module is already in
sys.modules; restart the process.
The most liberating fact is that sys.path is just data. It is a list. You can sys.path.insert(0, ...), .append(...), or reorder it at runtime, and the very next import obeys the new order. Import re-reads the list on every call. It never snapshots it. But before the walk even begins, import checks a faster cache: sys.modules, the dictionary of everything already imported in this process. A hit there means no search at all, and the module object is returned instantly. That is why the second import json is nearly free, and why a module's top-level code runs exactly once per process no matter how many files import it.
sys.modules — reading the cache while your program runsan ordinary dict keyed by import name; it answers "did it load?", "which file won?" and "why is my edit being ignored?"'os' and 'os.path' are two separate keys pointing at two separate module objects. A package and its submodules each get their own row.__file__ may be missingsys is compiled into the interpreter binary, so it has no file at all. Reach for it with getattr(mod, "__file__", None), never blindly.sys.modules["beat"] is beat is True. There is one module object and every importer holds a reference to that same one.del forces real workThis is the practical cure for "I edited my module and nothing changed". Evicting the key means the next import must find, compile and exec again.del strandsIt unloads nothing. Anything already holding the old module keeps the old functions, and now two live copies exist. In a long session, restarting is the honest move.importlib.reloadChapter 19's surgical version: it re-execs the source into the same object, so every name anyone already holds quietly sees the new code.you type
# ---------- beat.py ----------
# beat.py -- a module that announces itself when it executes
print("beat.py: top level running")
BPM = 128
# ---------- cache_probe.py ----------
# cache_probe.py -- the import cache, used as a debugging instrument
import os
import sys
print("loaded before we start :", len(sys.modules), "modules")
print("beat in the cache? :", "beat" in sys.modules)
import beat # MISS -- find, compile, exec; the print fires
import beat # HIT -- one dict lookup, and silence
print("beat in the cache? :", "beat" in sys.modules)
print("cache holds the object :", sys.modules["beat"] is beat)
print("which file won :", os.path.basename(sys.modules["beat"].__file__))
print("BPM :", beat.BPM)
beat.BPM = 200 # you patch the live module
del sys.modules["beat"] # evict it: the next import must do real work
import beat # MISS again -- the print fires a second time
print("BPM after re-import :", beat.BPM)
print("sys itself has no file :", hasattr(sys, "__file__"))you see
$ python cache_probe.py
loaded before we start : 35 modules
beat in the cache? : False
beat.py: top level running
beat in the cache? : True
cache holds the object : True
which file won : beat.py
BPM : 128
beat.py: top level running
BPM after re-import : 128
sys itself has no file : False- The startup count differs by version and platform. Thirty-five is not a fact to memorise; the scale is the point — you never start from zero.
- Two
import beatlines, onebeat.py: top level running. The second import printed nothing because nothing ran. - After
delplus re-import, yourbeat.BPM = 200is gone. You patched an object that is now stranded, and the name points at a fresh one. - Assigning into the cache is legal:
sys.modules["requests"] = fakeis how test frameworks stub modules, and how a bad import hook ambushes you. - A module can sit in
sys.moduleshalf-executed — that is the mid-load state from section 2, and it is why a circular import sometimes yields a module missing half its names. - Deleting a package key while its submodules stay cached leaves the dict inconsistent. Evict
pkgandpkg.subtogether, or just restart.
>>> import random
>>> random.__file__
'C:\\Users\\you\\project\\random.py' # not the stdlib — YOUR file won the search
>>> del sys.modules['random'] # evict the wrong module from the cache
>>> # now delete your local random.py, then import again → it falls through to the stdlibsys itself, and the C-level ones listed in sys.builtin_module_names — are compiled into the interpreter binary. A special finder answers for them before sys.path is ever consulted, so they can never be shadowed by a file. Don't let the file-based majority fool you into thinking import is only ever about opening .py files.sys.modules → on a miss, walk sys.path top to bottom → first directory that can produce the name wins → record the result back into sys.modules. Every shadowing bug you will ever hit is that loop finding a nearer match than you meant it to. Print the list; the ghost becomes a line number.The walk finds a directory that can produce the name — but who decides whether a folder "has" the module, and who turns the winning file into a live object? Those are two different jobs, done by two different kinds of object, and pulling them apart is what turns import from a keyword into a machine. Finders and loaders →
02Finders and loaders: the import protocol
You have watched import pull a module out of a .zip file. You have seen a Jupyter notebook import a sibling notebook that has no .py at all. If import were simply "open a .py and run it," none of that could exist. So there must be a seam — a place where "how do we find this?" is cleanly separated from "how do we build it?". Only a separated, swappable design could serve modules from a zip, a notebook, a database row, or thin air. Finding that seam is what turns import from a magic word into a machine with named parts you could implement yourself.
The real shape of import X is a two-phase protocol driven by importlib. Phase FIND: the machinery iterates sys.meta_path, a list of meta path finders, and asks each one find_spec(name, path, target). A finder returns either None, meaning "not mine, ask the next one," or a ModuleSpec. The three defaults, in order, are BuiltinImporter (the C builtins from the last section), FrozenImporter (modules frozen into the binary), and PathFinder, the one that finally consults sys.path. Print the chain and it is exactly what the mechanism predicts:
>>> import sys, json
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib_external.PathFinder'>]
>>> json.__spec__
ModuleSpec(name='json', loader=<...SourceFileLoader...>,
origin='C:\\Python312\\Lib\\json\\__init__.py',
submodule_search_locations=['C:\\Python312\\Lib\\json'])That ModuleSpec is the pivot of the whole system — the blueprint the rest of import consumes. Learn its fields, because they answer every "how did import know…" question you will ever have. .name is the dotted name. .loader is the object that will actually build the module. .origin is usually the source filename (or the literal string '(built-in)'). .submodule_search_locations is the __path__ a package hands to its children. .cached points at the .pyc. A finder's entire job is to fill in that form and hand it back.
ModuleNotFoundError checklistfour questions, four one-liners — find_spec turns "it can't find my module" from a mood into a measurementfind_spec is the finderIt is the same call the import machinery makes, run by you instead of by the import statement. There is no separate debug mode, because there is no need for one.None vs a specNone means no location on sys.path claims the name. A spec means the name resolves, and .origin tells you to which file.SyntaxError or a raising line inside the module still detonates later, at exec — a different stage, a different fix.ModuleNotFoundErrorA subclass of ImportError, so except ImportError catches both. The subclass exists so optional-dependency code can catch the missing case only.find_spec("pkg.sub") imports pkg as a side effect, and raises ModuleNotFoundError outright if pkg itself is missing. Check the parent first.sys.executableThe classic false mystery: pip install succeeded and the import still fails. You installed into a different interpreter than the one running your code.you type
# ---------- your folder ----------
check/
csv.py # a quick parser you wrote for one CSV, months ago
# ---------- the terminal ----------
$ python -c "import analytics"
$ python -c "import importlib.util as u; print(u.find_spec('analytics'))"
$ python -c "import importlib.util as u; print(u.find_spec('csv').origin)"
$ python -c "import sys; print('csv' in sys.stdlib_module_names)"
$ python -c "import sys; print(repr(sys.path[0]))"you see
$ python -c "import analytics"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'analytics'
$ ... print(u.find_spec('analytics'))
None
$ ... print(u.find_spec('csv').origin)
C:\tmp\check\csv.py
$ ... print('csv' in sys.stdlib_module_names)
True
$ ... print(repr(sys.path[0]))
''
- Read the two answers together.
find_specreturned a real file and the name belongs to the stdlib — that pair, and only that pair, means shadow. repr(sys.path[0])printed''because this waspython -c. Underpython app.pythe same line prints the script's own folder.- The first check is the cheapest and the one everyone skips: read the name back.
datetime, notDateTime;dateutil, notdate_util. find_specon a name already insys.modulesreturns the cached spec, so run it in a fresh process when you are hunting a shadow.- A missing third-party package and a shadowed stdlib name produce completely different errors. Guessing between them costs an hour; asking
find_speccosts a second. pip show Xtells you a package is installed somewhere. Onlyfind_specplussys.executabletells you whether this interpreter can reach it.
Phase LOAD is where the spec becomes a living thing. Import calls importlib.util.module_from_spec(spec) to make an empty module object. It inserts that object into sys.modules before running a single line of it. This is the subtle move that lets circular imports partially work: a module that imports back into a half-built one finds the half-built object already registered, rather than triggering an infinite re-import. Only then does import call spec.loader.exec_module(module), which runs the module's code object into the module's __dict__. The division of labour is crisp. A finder answers "where and how," and a loader does "create and exec." Nothing is hidden. You can perform the entire dance by hand:
import importlib.util, sys
def manual_import(name):
spec = importlib.util.find_spec(name) # PHASE FIND → a ModuleSpec
module = importlib.util.module_from_spec(spec) # create the empty module
sys.modules[name] = module # register BEFORE executing
spec.loader.exec_module(module) # PHASE LOAD → run the body
return module
textwrap = manual_import('textwrap')
print(textwrap.shorten("the whole import protocol, by hand", 20))One layer remains, and it explains the zip trick. PathFinder does not read files itself. For each entry on sys.path it consults sys.path_hooks to obtain a path entry finder for that specific location. The default hook handles ordinary directories, and another handles zip archives. It memoises the result in sys.path_importer_cache, so it only computes each one once per directory. So there are genuinely two finder layers: meta path finders are global strategies, and path entry finders are per-sys.path-entry workers. That is why a .zip on sys.path "just works": a path hook claims the archive and returns a finder that reads modules out of it.
# ---------- the broken layout ----------
jukebox/
app.py # imports json and playlist
json.py # a helper you wrote last month -- SHADOW
lib/
playlist.py # the module app.py wants -- WRONG FOLDER
import_detective.py
# ---------- app.py ----------
# app.py -- print the playlist as JSON
import json
import playlist
print(json.dumps(playlist.load()))
# ---------- lib/playlist.py ----------
# playlist.py -- the data the app prints
TRACKS = [
{"title": "Blinding Lights", "seconds": 200},
{"title": "Titanium", "seconds": 245},
]
def load():
return TRACKS
# ---------- import_detective.py ----------
# import_detective.py -- answer "why can't Python import that?" in four checks
import importlib.util
import os
import sys
HERE = os.path.realpath(os.getcwd())
def short(path):
"""A readable path: relative to the project when the file is inside it."""
real = os.path.realpath(path)
if real.startswith(HERE):
return os.path.relpath(real, HERE).replace(os.sep, "/")
return "/".join(real.replace(os.sep, "/").split("/")[-2:])
def files_named(name):
"""Every <name>.py under the project -- on sys.path or not."""
hits = []
for root, dirs, files in os.walk(HERE):
dirs[:] = [d for d in dirs if d not in {"__pycache__", ".git", ".venv"}]
if name + ".py" in files:
hits.append(short(os.path.join(root, name + ".py")))
return hits
def investigate(name):
print("---", name, "---")
print("1 in sys.modules :", name in sys.modules)
spec = importlib.util.find_spec(name)
if spec is None:
print("2 find_spec : None -- no sys.path entry answers to that name")
hits = files_named(name)
if hits:
print("3 file on disk :", ", ".join(hits))
print(" VERDICT : right file, wrong folder -- it is not on sys.path")
else:
print("3 file on disk : none -- check the spelling, or pip install it")
print(" VERDICT : the name does not exist here")
return
origin = spec.origin or "(built-in)"
mine = os.path.realpath(origin).startswith(HERE)
print("2 find_spec :", short(origin))
print("3 whose file :", "yours" if mine else "not yours")
if mine and name in sys.stdlib_module_names:
print(" VERDICT : SHADOW -- your file beats the stdlib module of that name")
else:
print(" VERDICT : import will succeed with that file")
for wanted in sys.argv[1:]:
investigate(wanted)
$ python app.py
Traceback (most recent call last):
File "C:\tmp\jukebox\app.py", line 3, in <module>
import playlist
ModuleNotFoundError: No module named 'playlist'
$ python import_detective.py playlist json
--- playlist ---
1 in sys.modules : False
2 find_spec : None -- no sys.path entry answers to that name
3 file on disk : lib/playlist.py
VERDICT : right file, wrong folder -- it is not on sys.path
--- json ---
1 in sys.modules : False
2 find_spec : json.py
3 whose file : yours
VERDICT : SHADOW -- your file beats the stdlib module of that name
$ mv lib/playlist.py playlist.py && rmdir lib
$ mv json.py pairs.py
$ python app.py
[{"title": "Blinding Lights", "seconds": 200}, {"title": "Titanium", "seconds": 245}]
$ python import_detective.py playlist json
--- playlist ---
1 in sys.modules : False
2 find_spec : playlist.py
3 whose file : yours
VERDICT : import will succeed with that file
--- json ---
1 in sys.modules : False
2 find_spec : json/__init__.py
3 whose file : not yours
VERDICT : import will succeed with that file
app.py first. The traceback names one problem — playlist — and stops there, which is exactly the trap. Fix that one, re-run, and a second, stranger failure is waiting behind it. So run the detective instead, on both names at once, before touching anything. It reports two entirely different diagnoses. playlist gets find_spec: None, which means no folder on sys.path claims that name — and then the walk finds lib/playlist.py sitting right there on disk. Right file, wrong folder: the file exists, and lib/ is simply not a place import looks. json gets the opposite verdict. It does resolve, to json.py, the file is yours, and json is a name the standard library already owns. That is the shadow, caught before it ever raised anything. Notice what makes the tool honest: short() prints paths relative to your project, so a shadow reads as a bare filename and the real standard library reads as json/__init__.py — one glance tells them apart. Two fixes, both boring: move the file up beside app.py, and rename yours to pairs.py. Now the same two questions come back clean, and app.py prints its JSON. Two things to try. Point the detective at a name that does not exist anywhere — python import_detective.py analytiks — and watch it say so plainly instead of guessing. Then add a print(sys.path[0]) to investigate, run it from lib/, and watch the whole verdict change because the first entry changed.find_spec → module_from_spec → exec_module. A finder produces a ModuleSpec; a loader turns that spec into a live module executed into its own __dict__. Every step is an ordinary object sitting on an ordinary list — which is exactly why, two sections from now, you will be able to add your own.The loader ran the module's code object — but where did that code object come from, and why is the second import so much faster than the first? The answer is a folder you have seen appear next to your code and always been slightly afraid to delete. __pycache__ →
03Source becomes bytecode; __pycache__ caches it
The first time you import a large library there is a perceptible pause. The second time it is instant. And sitting next to your code, unbidden, is a folder called __pycache__ full of .pyc files. You have wondered three things about it and never quite asked out loud: is it safe to delete, why does it exist, and did my source just get "compiled" like C? Answer those and you have demystified startup time, an entire family of "stale bytecode" bugs, and exactly what Python is trading memory for speed on.
__pycache__ — what is in it, and why deleting it is always safeone .pyc per imported module, stamped with the exact interpreter that wrote it__main__ gets no .pyc, which is why the folder holds your libraries and not your entry point.cache_from_sourceStop hand-building that filename. This is the function the loader itself uses, so it is right on every platform and every implementation.-B and PYTHONDONTWRITEBYTECODEUseful in containers and on read-only mounts. You pay the compile every start, which for a small program is a cost you cannot feel..pyc hides nothing. dis reads it straight back into named opcodes with your variable names attached — shipping bytecode is not shipping a secret.you type
# ---------- your folder ----------
# beat.py -- a library module, IMPORTED by player.py
# player.py -- the program you launch
# ---------- player.py ----------
# player.py -- the program you launch
import beat
print("BPM is", beat.BPM)
# ---------- the terminal ----------
$ ls
$ python player.py
$ ls __pycache__
$ rm -rf __pycache__
$ python -B player.py
$ lsyou see
$ ls
beat.py
player.py
$ python player.py
beat.py: top level running
BPM is 128
$ ls __pycache__
beat.cpython-312.pyc # beat was IMPORTED. player.py was RUN -- no .pyc.
$ rm -rf __pycache__
$ python -B player.py
beat.py: top level running
BPM is 128
$ ls
beat.py
player.py # -B wrote nothing- The missing
player.cpython-312.pycis not a bug. The launched script is__main__, and__main__is never cached. - An orphaned
.pycwhose source has been deleted is not importable. Since Python 3.2 a__pycache__entry alone can never satisfy an import. - Add
__pycache__/to.gitignoreonce. Committing it is harmless and useless, and it produces a merge conflict in a binary file eventually. - The top-level
printfires on every run, cached or not. The.pycspares you the compile; it never spares you the execution. - A stale
.pycis possible but rare, and it needs a clock going backwards — a restored old file, a skewed build machine. Section 3's hash mode exists for exactly that. - Deleting
__pycache__in the middle of a running process changes nothing. The module is already insys.modules, and nothing re-reads disk.
sys.path — it is modules actually imported, each running its own top-level code exactly once and caching itself in sys.modules. Counts are typical CPython 3.12 order-of-magnitude figures; your exact totals vary by version and platform.import X is rarely one file. A heavy library is the root of a dependency tree, and importing it executes the top-level code of every module in that tree — hundreds for pandas. The .pyc cache removes the recompile cost on later runs and sys.modules removes the re-import cost within a run, but the sheer count of modules is why a cold import pandas is felt and a cold import sys is not.Python is compiled — just not to machine code. Every time a .py is imported, its source is compiled into a code object full of bytecode. (The one exception is the script you run directly as __main__; that one is never cached.) To avoid paying that compile cost on every future import, the loader marshals the code object to bytes and writes them to __pycache__/NAME.cpython-3XX.pyc. The whole trick lives in that file's header, so let us take it apart byte by byte. A modern .pyc begins with sixteen bytes. First comes a 4-byte magic number that encodes the exact CPython version and bytecode format. This is why the filename is tagged cpython-312, and why a 3.11 .pyc is silently ignored by 3.12. Next comes a 4-byte bit-field of flags. Then, in the default timestamp scheme, come the source's 4-byte modification time plus its 4-byte size.
Now the payoff. On the next import the loader reads only that header. It compares the magic number: mismatch means a different interpreter, so recompile. If the magic matches, in timestamp mode it compares the stored mtime and size against a fresh os.stat of the source. If they match, it concludes "the source is unchanged, trust the cache," skips tokenizing, parsing and compiling entirely, and simply marshal.loads the code object straight back into memory to execute. That is why the second import is fast: the expensive front of the pipeline is skipped, and only unmarshal-plus-exec remain. Decode the header yourself and the machine stops being mysterious:
import struct, importlib.util, os
with open("__pycache__/mymod.cpython-312.pyc", "rb") as f:
magic = f.read(4)
flags = struct.unpack("<I", f.read(4))[0]
mtime = struct.unpack("<I", f.read(4))[0]
size = struct.unpack("<I", f.read(4))[0]
print(magic == importlib.util.MAGIC_NUMBER) # True — this interpreter's format
print(mtime, int(os.stat("mymod.py").st_mtime)) # equal → cache is trustedBe exact about the failure mode, because it bites real builds. Timestamp invalidation trusts the filesystem clock. Restore an older source whose mtime is now newer than the cache, or build on a machine with a skewed clock, and Python can serve you stale bytecode — running code that no longer matches the source you are reading. That is precisely why hash-based .pyc files exist (PEP 552). When the flags bit is set, those mtime and size fields are replaced by a truncated SipHash of the source, and the loader validates by content rather than by clock. You opt in with py_compile using CHECKED_HASH (rehash and compare every time) or UNCHECKED_HASH (trust the hash blindly, for reproducible builds where the toolchain guarantees freshness).
# pycache_watch.py -- watch the .pyc be trusted, go stale, and be rebuilt
import importlib.util
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path
SRC = Path("beat.py")
PYC = Path(importlib.util.cache_from_source(str(SRC))) # the exact cache path
SRC.write_text('print("beat.py: top level running")\n\nBPM = 128\n', encoding="utf-8")
def header_mtime():
"""Bytes 8-12 of the .pyc: the source mtime it claims it was built from."""
magic, flags, mtime, size = struct.unpack("<4sIII", PYC.read_bytes()[:16])
return mtime
def fresh_import():
"""A brand-new process, so sys.modules starts empty and import really runs."""
done = subprocess.run([sys.executable, "-c", "import beat; print('BPM =', beat.BPM)"],
capture_output=True, text=True)
for line in done.stdout.splitlines():
print(" |", line)
shutil.rmtree(PYC.parent, ignore_errors=True)
print("cold : .pyc exists?", PYC.exists())
print("run 1 : the first import ever")
fresh_import()
m1 = header_mtime()
print(" wrote", PYC.name)
print(" header mtime == source mtime :", m1 == int(SRC.stat().st_mtime))
print("run 2 : same source, brand-new process")
fresh_import()
m2 = header_mtime()
print(" header mtime changed? :", m2 != m1, "-- COMPILE was skipped")
SRC.write_text(SRC.read_text(encoding="utf-8").replace("128", "174"), encoding="utf-8")
os.utime(SRC, (SRC.stat().st_atime + 2, SRC.stat().st_mtime + 2))
print("edit : BPM 128 -> 174, saved")
print(" header mtime == source mtime :", m2 == int(SRC.stat().st_mtime), "-- STALE")
print("run 3 : after the edit")
fresh_import()
m3 = header_mtime()
print(" header mtime changed? :", m3 != m2, "-- it recompiled")
print(" header mtime == source mtime :", m3 == int(SRC.stat().st_mtime))
$ python pycache_watch.py
cold : .pyc exists? False
run 1 : the first import ever
| beat.py: top level running
| BPM = 128
wrote beat.cpython-312.pyc
header mtime == source mtime : True
run 2 : same source, brand-new process
| beat.py: top level running
| BPM = 128
header mtime changed? : False -- COMPILE was skipped
edit : BPM 128 -> 174, saved
header mtime == source mtime : False -- STALE
run 3 : after the edit
| beat.py: top level running
| BPM = 174
header mtime changed? : True -- it recompiled
header mtime == source mtime : True
beat.py itself, so you can run it as many times as you like. Every import here happens in a separate process, launched by subprocess, because that is the only way to see this cache work. Within one process sys.modules would answer instantly and the .pyc would never be consulted at all. Read the three runs as one sentence. Run 1 finds no cache, compiles, and writes a header stamped with the source's modification time — and header mtime == source mtime is True. Run 2 is a brand-new process with an empty sys.modules, so it must find and execute beat.py again; the header is unchanged, which is the visible proof that compile() never ran. Then we edit and save. The stamp and the file disagree, the .pyc is stale, and run 3 rebuilds it — new stamp, matching again, and BPM = 174. Two details are worth your attention. beat.py: top level running appears three times, once per process: the cache spares you COMPILE and never EXEC. And the os.utime line nudges the modification time forward two seconds, because filesystem timestamps have one-second teeth. A save and a re-import inside the same clock second can leave the stamp looking unchanged — which is the whole honest reason hash-based .pyc files exist. Two things to try. Delete the os.utime line, run it again, and see whether run 3 still recompiles on your machine. Then insert a syntax error into beat.py before run 3 and notice which stage the traceback names.✗ The myth
"__pycache__ is compiled, protected, or somehow important — deleting it might break my program or leak my source."
✓ The reality
A .pyc is a cache and nothing more — always safe to delete; Python simply regenerates it on the next import at the cost of one recompile. It is not encryption and not protection: the bytecode is trivially disassembled. And marshal is not pickle — it is an internal, version-specific format for code objects only, deliberately not portable across versions.
sys.dont_write_bytecode = True, or launch with python -B, and Python compiles in memory but never writes a .pyc. Import still works from a read-only directory too — it just recompiles every time rather than caching. The cache is an optimisation, never a requirement.The cache stores a code object — the compiled unit the loader executes. But where does that object come from? Between the characters you type and the bytecode the VM runs sit three transformations, and the next section freeze-frames every one of them. Text → AST → code object →
04The compile pipeline: source → AST → code object
Volume 1 handed you a virtual machine that eats bytecode and pushes values on a stack. But you do not write bytecode — you write text. Somewhere between the characters your fingers produce and the instructions the VM consumes, three transformations happen. While they stay invisible, the VM feels disconnected from the language you actually speak. This section closes the loop. It shows the exact assembly line that turns total = a + b into the LOAD_FAST/BINARY_OP/STORE_FAST instructions your Volume 1 stack machine already knows how to run. And it hands you two tools, ast and dis, to stop the line at any station and stare.
# save BOTH files to disk first, then run the second one
# ---------- halfgood.py ----------
print('the first line ran')
BPM = 128
print('and the third'
# ---------- main.py ----------
print("nothing has complained yet") # the file with a hole in it is already on disk
import halfgood # ...and only NOW does it reach the compiler
$ python main.pynothing has complained yet
Traceback (most recent call last):
File "C:\tmp\jukebox\main.py", line 2, in <module>
import halfgood
File "C:\tmp\jukebox\halfgood.py", line 3
print('and the third'
^
SyntaxError: '(' was never closedhalfgood.py was sitting on disk before this process existed, so a sweep at launch would have had to walk straight into it. Instead the run's first act is nothing has complained yet arriving on your screen — the program printed, and a file in it had not compiled. There was no sweep, because Python compiles one file at a time, at the instant the import line for it runs — a module nobody imports is never compiled and never complains. Now read what the output does not contain, which is worth as much: the first line ran never printed, even though that file finally did reach the compiler. So within the one file that got there it is all-or-nothing — the whole text becomes a code object before its first instruction executes, which is exactly why compile() is a single call in this section rather than a loop over lines. One honest refinement, because a build step is available: python -m compileall . walks a tree and compiles every file ahead of time, and it would have caught halfgood.py this morning. Nothing runs it for you. Every import statement you write is a compile you have deferred until that line executes — and, once a matching .pyc exists, a compile you paid once and never pay again.Station 1 — TOKENIZE. The tokenizer sweeps the raw source string into a stream of typed tokens: NAME, OP, NUMBER, NEWLINE, and — the detail that explains a whole category of beginner pain — INDENT and DEDENT. Python's significant indentation is not a special case buried in the parser. It is literal tokens the lexer synthesises when the leading whitespace changes. That is why mixing tabs and spaces is a tokenizer-level error, thrown before the grammar is ever consulted. Watch the stream directly:
import tokenize, io
src = "total = a + b\n"
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
print(tokenize.tok_name[tok.type], repr(tok.string))
# NAME 'total' · OP '=' · NAME 'a' · OP '+' · NAME 'b' · NEWLINE '\n' · ENDMARKER ''Station 2 — PARSE to an AST. Since 3.9 CPython uses a PEG parser that assembles those tokens into an Abstract Syntax Tree. That is a tree of typed nodes where precedence is baked into the shape and all formatting is gone. total = a + b becomes an Assign whose target is Name('total') and whose value is a BinOp(Name('a'), Add(), Name('b')). This is the same tree that decorators, linters, and this book's own tracer inspect — structure without a single stray space or comment:
import ast
print(ast.dump(ast.parse("total = a + b"), indent=2))
# Module(body=[
# Assign(targets=[Name(id='total', ctx=Store())],
# value=BinOp(left=Name(id='a'), op=Add(), right=Name(id='b')))])Station 3 — COMPILE to a code object. The compiler walks the AST, builds a control-flow graph, and emits bytecode into a code object (types.CodeType). This is the real unit of executable Python — the thing a function has (func.__code__) and a module is. Its fields are the accumulated payload of everything upstream: co_code (the raw bytecode bytes), co_consts (literals and nested code objects), co_names (global and attribute names), co_varnames (locals), co_argcount, co_flags, and a line-number table mapping bytecode offsets back to source lines for tracebacks. Then dis.dis disassembles co_code into readable opcodes. And here the whole book snaps together, because these are the exact stack instructions from Volume 1:
import dis
def f(a, b):
total = a + b
return total
dis.dis(f)
# LOAD_FAST a push local a onto the value stack stack: [a]
# LOAD_FAST b push local b stack: [a, b]
# BINARY_OP + pop two, push their sum stack: [445]
# STORE_FAST total pop into the local `total` stack: []
# LOAD_FAST total push it again to return stack: [445]
# RETURN_VALUE pop and hand it back to the callerAnd now the tie that binds the chapter: compile(src, name, 'exec') is literally stations 1 through 3 in a single call, returning the code object. Import's compile step is this pipeline. The .pyc from the last section caches its station-3 output, and the loader from section two runs that output into a module's __dict__. One unbroken arc: text → tokens → AST → code object → (marshalled into a .pyc) → executed by the VM. compile even hands you a code object you can eval directly:
code = compile("a + b", "<demo>", "eval") # tokenize + parse + compile
print(code.co_names) # ('a', 'b')
print(eval(code, {"a": 200, "b": 245})) # 445 — Titanium meets Blinding Lights againast freezes the middle stage, dis freezes the last, and compile runs all three — the language you type and the machine that runs it are the same object seen at different stations.You now know how one file becomes a module. But a real codebase splits one logical package across many folders — and the rule you were taught, "a package needs an __init__.py," turns out to be only half true. Namespace packages →
05Namespace packages: folders without __init__.py
Someone taught you a firm rule: a package needs an __init__.py. Then you clone a large monorepo where import company.billing and import company.auth resolve to two completely different directories. And there is no __init__.py in sight, yet every import succeeds. Either the rule you learned is wrong, or something deeper is running underneath it. Something deeper is running underneath it, and it is exactly how big organisations split one logical package across many folders, repositories, and separately installed distributions.
There are two kinds of package, and the difference is one file. A regular package is a directory containing __init__.py. Importing it runs that file and sets the package's __path__ to a single-element list — the one directory it came from. A namespace package (PEP 420, since 3.3) is what you get when the import system finds directories matching the name across sys.path but none of them contains __init__.py. Here is the precise algorithm PathFinder runs while scanning for company. If an entry has company/__init__.py, that is a regular package — stop. If an entry has a plain directory company/ with no __init__.py, record that directory as a portion and keep scanning the rest of sys.path, collecting every matching directory. If the scan ends without ever finding a regular package or a company.py module, import synthesises a namespace package. Its __path__ is not a plain list but a live _NamespacePath object holding all the collected portions.
dirA/company/billing.py # no __init__.py anywhere under company/
dirB/company/auth.py # a second, separate portion of the same name>>> import sys
>>> sys.path[:0] = ['dirA', 'dirB']
>>> import company.billing, company.auth # both succeed
>>> company.__file__ # None — no single file backs it
>>> type(company.__path__)
<class '_frozen_importlib_external._NamespacePath'>
>>> list(company.__path__)
['dirA/company', 'dirB/company'] # both portions, mergedThat merged __path__ is the entire mechanism. import company from dirA and import company.auth where auth lives in dirB both work, because company.__path__ spans both directories, and submodule search walks every portion. Contrast the two sharply. A regular package is one directory that runs __init__.py — a real place to put initialisation code, an __all__, side effects, a version string. A namespace package is zero-or-many directories, runs no code, has __file__ set to None, and carries a live multi-location __path__ that is recomputed lazily as sys.path changes.
The design tradeoff is worth naming, because it is why the feature exists at all. Namespace packages let separately installed distributions contribute subpackages under one shared top-level name. The classic case is pip install azure-storage and pip install azure-identity landing in different site-packages folders, yet both importable as azure.storage and azure.identity. You gain that composability. You lose a place to put package-init code. And you inherit a subtler hazard. An accidental empty folder named like a package cannot beat a real installed one — a regular package with __init__.py still wins the scan. What the stray folder does is worse in a quieter way: when the real package is missing (a typo'd install, a broken checkout), the import that should fail loudly now "succeeds" as an empty namespace package. The tell is always the same pair of fields:
>>> import json # a regular package
>>> json.__file__ # '.../Lib/json/__init__.py' — a real file
>>> json.__path__ # ['.../Lib/json'] — a plain list, one entry
>>> # namespace package: __file__ is None, type(__path__) is _NamespacePathimport mypkg works but fails deep inside with a missing submodule, check mypkg.__file__. If it is None, you imported a namespace package — and if you expected a regular one, a stray directory named mypkg/ on sys.path is answering for a package that is not actually installed. The loud ModuleNotFoundError you should have seen was swallowed. Install the real package, or delete the impostor folder.You have now seen finders on a list, a spec protocol, and a default filesystem implementation with two flavours of package. The obvious, powerful question remains: if finders are just objects on a list — can you add one? Import hooks →
06Import hooks: why the protocol is pluggable
You know the shape now: import is find_spec → module_from_spec → exec_module, and finders live on a plain Python list. Which raises the question this whole chapter has been walking toward — if it is just objects on a list, can I add my own? Yes. And that yes is the entire difference between a language feature you use and a mechanism you own. Import hooks are how tools import notebooks as modules, how packages get lazily loaded, how encrypted or remote code gets fetched and run. And, more to the point, they are how you would build any of those yourself, in pure Python, at runtime.
There are two extension points, and choosing between them is choosing your blast radius. Meta path hooks are the sledgehammer. Put a finder object on sys.meta_path, and because that list is consulted first and for every single import, your finder can intercept any name from any source — a database row, a URL, an in-memory dictionary, a decrypted blob. It needs one method, find_spec(name, path, target), returning a ModuleSpec or None to politely decline. Here is the minimal shape: a finder that serves modules whose source is just a string in memory, with no file behind them at all:
import sys, importlib.util, importlib.abc
SOURCES = {"greet": "def hello():\n return 'hi from memory'\n"}
class DictLoader(importlib.abc.Loader):
def __init__(self, src): self.src = src
def create_module(self, spec): return None # use the default empty module
def exec_module(self, module):
code = compile(self.src, module.__name__, "exec") # the section-4 pipeline!
exec(code, module.__dict__) # run into the module namespace
class DictFinder(importlib.abc.MetaPathFinder):
def find_spec(self, name, path, target=None):
if name in SOURCES:
return importlib.util.spec_from_loader(name, DictLoader(SOURCES[name]))
return None # decline → the next finder is asked
sys.meta_path.insert(0, DictFinder())
import greet # no file on disk exists
print(greet.hello()) # hi from memoryNotice what you just did. Import fetched source from a Python dict, ran it through compile — the exact pipeline from section four — and executed the resulting code object into a module's __dict__, the exact load phase from section two. Every piece of this chapter reappears, now under your control.
report/ holding two files. json.py is yours, from months ago, and contains one function: as_rows(pairs) returning [{"name": k, "n": v} for k, v in pairs]. save_report.py is today's work: it does import json, builds ROWS = [{"track": "Titanium", "plays": 12}], opens report.json for writing with encoding="utf-8", calls json.dump(ROWS, f), and prints a confirmation. Run it and copy the traceback down. Second, answer three questions before you touch a single file. Which of the four import stages produced this — find, compile, exec, or cache? Why is it an AttributeError and not a ModuleNotFoundError, given that the module you wanted was never loaded? And which single line, run in the same folder, proves the diagnosis rather than suggesting it? Write those three answers out; the fix is worthless if you cannot name the cause. Third, fix it, then check the corner nobody checks. Rename your file, re-run, and confirm report.json is written. Then list __pycache__ — the compiled copy of your old json.py is still sitting there. Decide, with a reason, whether it can still be imported, and prove your answer with importlib.util.find_spec. One hint and no more: sys.stdlib_module_names would have stopped this on the day you named the file.show the solution
# ---------- report/json.py (YOURS -- the shadow) ----------
# json.py -- a helper you wrote last week and forgot about
def as_rows(pairs):
return [{"name": k, "n": v} for k, v in pairs]
# ---------- report/save_report.py ----------
# save_report.py -- write the play counts to disk
import json
ROWS = [{"track": "Titanium", "plays": 12}]
with open("report.json", "w", encoding="utf-8") as f:
json.dump(ROWS, f)
print("wrote report.json")
# ------------- $ python save_report.py -------------
Traceback (most recent call last):
File "C:\tmp\report\save_report.py", line 7, in <module>
json.dump(ROWS, f)
^^^^^^^^^
AttributeError: module 'json' has no attribute 'dump'
# The three answers.
#
# 1. FIND. Stage one walked sys.path, and sys.path[0] -- your own folder --
# answered "yes, I have json" before the standard library was ever asked.
#
# 2. Because the import SUCCEEDED. Nothing was missing, so nothing raised at
# import time. Python found a module named json, ran it, and bound it. The
# failure came later, on attribute lookup, when the module it handed you
# turned out to have as_rows and no dump. ModuleNotFoundError means "no file
# answered"; AttributeError here means "the wrong file answered".
#
# 3. One line, and it ends the argument:
# ------------- $ python -c "import json; print(json.__file__)" -------------
C:\tmp\report\json.py
# ------------- the fix: rename YOUR file -------------
$ mv json.py rows.py # ren json.py rows.py on Windows
$ python save_report.py
wrote report.json
$ cat report.json
[{"track": "Titanium", "plays": 12}]
$ python -c "import os, json; print('/'.join(json.__file__.replace(os.sep,'/').split('/')[-2:]))"
json/__init__.py # the real one, at last
# ------------- the corner nobody checks -------------
$ ls __pycache__
json.cpython-312.pyc # your old file's bytecode, still there
# Can it still be imported? No -- and the proof is one call:
$ python -c "import importlib.util as u, os; print(os.path.basename(u.find_spec('json').origin))"
__init__.py
# Since Python 3.2 a .pyc inside __pycache__ is only ever consulted as the cache
# of a source file sitting beside it. Delete the .py and the .pyc is inert: the
# finder is looking for json.py, not for json.cpython-312.pyc. So the leftover is
# harmless clutter -- delete the folder if it bothers you, and nothing changes.
# The one honest sentence to take away:
# sys.path[0] is your folder, so naming a file is choosing which import wins.
# Check the name against sys.stdlib_module_names BEFORE you save, not after.The second extension point, path hooks, is the scalpel. Put a callable on sys.path_hooks; each hook is handed a sys.path entry string and either returns a path entry finder for it or raises ImportError to decline. This only affects things reachable through sys.path entries — which is precisely how zipimport works. The zipimporter is a path hook that claims .zip entries, and the sys.path_importer_cache memoises the mapping so the archive is opened once. That is the full explanation of a trick that has always looked like sorcery:
>>> import sys
>>> sys.path.insert(0, "libs.zip") # a path to an ARCHIVE, not a folder
>>> import packed_module # read straight out of the zip
>>> sys.path_importer_cache["libs.zip"]
<zipimporter object "libs.zip"> # the path hook that claimed itHold the precedence in your head as one ordered pipeline. The sys.modules cache comes first. Then come the sys.meta_path finders in order — yours can go at the front to override, or at the back to be a fallback. PathFinder is itself just the last meta path finder, and it fans out to sys.path_hooks per entry. For correctness a well-behaved loader sets module.__spec__, __name__, __loader__, and __file__ (or leaves __file__ unset for non-file sources), and executes into module.__dict__ via exec_module. Meanwhile the machinery, not your loader, handles inserting into sys.modules and unwinding on error.
Traceback (most recent call last):
File "C:\tmp\mono\app.py", line 1, in <module>
from company.auth import login
ModuleNotFoundError: No module named 'company.auth'; 'company' is not a packagefrom company.auth import login is not one import, it is two: import company, then look for auth inside it — and the first one succeeded. Section 1's walk reached sys.path[0], your project folder, found a company.py that somebody added last quarter for one report, and first match wins, so it stopped. Now import needs to look inside that result, and a module has no inside: __path__ — the list of portions from section 5 — exists only on packages, and importlib.util.find_spec('company').submodule_search_locations comes back None. That None is this message. What makes it sting is that the real company/ directory is sitting right beside the file and the walk never weighed it, because within a single sys.path entry a directory is only checked ahead of a same-named .py when it holds an __init__.py. Put one in and the package wins outright; leave it out and the directory is merely a namespace portion, which a sibling company.py outranks. Same folder, same two names, and one empty file decides which of them import can see.find_spec('company').origin before you read anything else — if it names a .py, rename that file; the parent of a dotted import has to be a directoryTraceback (most recent call last):
File "C:\tmp\shop\checkout.py", line 1, in <module>
import cart
File "C:\tmp\shop\cart.py", line 2, in <module>
import pricing
File "C:\tmp\shop\pricing.py", line 4, in <module>
RATE = cart.TAX
^^^^^^^^
AttributeError: partially initialized module 'cart' has no attribute 'TAX' (most likely due to a circular import)sys.modules before a single statement of it runs — and the traceback shows you both what that buys and what it costs. Read the frames downward: checkout asks for cart; cart, two lines into its own body, asks for pricing; pricing, four lines into its body, reaches back for cart.TAX. That second request for cart did not recurse forever, and that is the payoff: the cache already held a cart object, so import handed it straight over. But cart is still suspended on its line 2, and TAX is defined on its line 4. You were given the real module, correctly bound, genuinely missing the attribute. Two words are worth reading slowly. Partially initialized is the interpreter stating the exact truth rather than guessing — not a missing module, not a typo, a module caught mid-exec. And AttributeError, not ImportError: nothing failed to import here. The import system did everything right; the two files simply need each other's finished state at a moment when neither one has it.import cart in the body of a with_tax() in pricing.py runs long after both module bodies have finished; or lift the shared constant into a third module that imports nobodyTraceback (most recent call last):
File "C:\tmp\pycbox\player.py", line 2, in <module>
import beat
ImportError: bad magic number in 'beat': b'\xa7\r\r\n'b'\xa7\r\r\n' is the magic CPython 3.11 stamps on everything it compiles; this interpreter is 3.12, whose magic is b'\xcb\r\r\n', and the loader compares those four bytes before it reads a single one beyond them. (To reproduce it in one line we wrote 3.11's magic onto a 3.12 file; a real build does it by copying .pyc files between images.) Now the part worth keeping, because it refines a rule section 3 states exactly. A .pyc living in __pycache__ can never satisfy an import on its own — and with the source still present, a wrong magic raises nothing at all: the loader silently recompiles and overwrites the stale file, which is the whole point of the version tag in the filename. Getting this traceback takes the older, flatter layout: a bare beat.pyc sitting beside your code with no beat.py anywhere. In that legacy position a SourcelessFileLoader claims it and the .pyc has to answer for itself, with no source to fall back on. So the message is not really about corruption. It is bytecode telling you it was never portable, and that the only reason you normally never hear about it is that a .py is usually standing right behind it..pyc files between interpreters — delete the stray one, keep the .py, and let this Python write its own into __pycache__sys.path, then fix it three waysstudio/ with three files. metrics.py at the top level defines mean(values) returning sum(values) / len(values). run_report.py, also at the top level, does import metrics and prints the mean of [200, 245, 320]. Then notebooks/explore.py, one folder down, contains the identical two lines — and before them, a probe: print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)"). No __init__.py anywhere. Second, run three commands from inside studio/ and predict each before you press Enter. Try python run_report.py, then python notebooks/explore.py, then python -m notebooks.explore. One of the three fails. Explain, in one sentence that mentions no folder you did not print, exactly why the same import metrics line succeeds under one command and raises under another — and say why running cd notebooks first changes nothing. Third, fix it three ways and rank them. Fix A: change nothing in the code and use the launch form that already works. Fix B: append the project root to sys.path from inside explore.py, anchored to __file__ rather than to the working directory. Fix C: append the bare string ".." instead, then run the file twice — once from inside notebooks/, once from studio/ — and watch it work and then die. Say which of the three you would ship, and why. One hint and no more: the third command works even though notebooks/ has no __init__.py, and section 5 explains that.show the solution
# ---------- studio/metrics.py ----------
# metrics.py -- the shared maths
def mean(values):
return sum(values) / len(values)
# ---------- studio/run_report.py ----------
# run_report.py -- lives beside metrics.py
import metrics
print("mean:", metrics.mean([200, 245, 320]))
# ---------- studio/notebooks/explore.py ----------
# notebooks/explore.py -- the SAME import line, one folder down
import os
import sys
print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)")
import metrics
print("mean :", metrics.mean([200, 245, 320]))
# ------------- all three, run from inside studio/ -------------
$ python run_report.py
mean: 255.0
$ python notebooks/explore.py
sys.path[0] : notebooks
Traceback (most recent call last):
File "C:\tmp\studio\notebooks\explore.py", line 7, in <module>
import metrics
ModuleNotFoundError: No module named 'metrics'
$ python -m notebooks.explore
sys.path[0] : studio
mean : 255.0
# The explanation, in one sentence:
# sys.path[0] is the folder of the SCRIPT you launched -- notebooks -- and
# metrics.py is not in notebooks, so the search never reaches it.
#
# Which is also why `cd notebooks` first changes nothing: the script form does
# not care where you are standing, only where the file is. Only -m is different,
# because -m puts the CURRENT DIRECTORY at index 0 instead -- studio, where
# metrics.py actually lives. Same file, same import line, different first entry.
#
# And -m works with no __init__.py because notebooks/ resolves as a namespace
# package (section 5) -- enough to be imported through, which is all -m needs.
# ------------- fix B: anchored to the FILE -------------
# in notebooks/explore.py, above `import metrics`:
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parent.parent)) # studio/
$ python notebooks/explore.py # run from studio
sys.path[0] : notebooks
mean : 255.0
$ cd .. && python studio/notebooks/explore.py # run from one folder up
sys.path[0] : notebooks
mean : 255.0 # still fine: anchored to __file__
# ------------- fix C: anchored to nothing -------------
# ---------- notebooks/explore.py, fix C ----------
# notebooks/explore.py -- the SAME import line, one folder down
import os
import sys
print("sys.path[0] :", os.path.basename(sys.path[0]) or "(cwd)")
sys.path.append("..") # relative to the WORKING DIRECTORY
import metrics
print("mean :", metrics.mean([200, 245, 320]))
$ cd notebooks
$ python explore.py
sys.path[0] : notebooks
mean : 255.0 # works: ".." happens to BE studio
$ cd ..
$ python notebooks/explore.py
sys.path[0] : notebooks
Traceback (most recent call last):
File "C:\tmp\studio\notebooks\explore.py", line 8, in <module>
import metrics
ModuleNotFoundError: No module named 'metrics'
# Same file. Same line. The only thing that changed is where you were standing
# when you pressed Enter -- because ".." is resolved against the CWD, and the
# CWD is not a property of your project.
# Ranked, with reasons:
# A (python -m from the project root) -- ship this. Zero code, and it is the
# same command your test runner and your CI already use.
# B (append Path(__file__)...) -- acceptable when a file must be runnable on
# its own. It survives a cd, but it hides a real layout problem in a
# script that now edits interpreter state before it does any work.
# C (append "..") -- never. It depends on where the human was standing, which
# is the one thing you cannot put in a repository.
#
# The one honest sentence to take away:
# "It works on my machine" is usually "sys.path[0] was different on my machine."sys.meta_path is a genuine supply-chain risk. The same power that lets you import from a URL lets a malicious dependency intercept import requests and hand you something else entirely. Extensibility and trust are the same lever pulled in opposite directions.You can now see, extend, and even hijack the machine that turns disk bytes into live modules. Next: once a module is live, what happens when your program needs to send its objects to another process — where nothing but bytes can cross? Serialization and the pickle pipe →
- Import is not a search of your computer, it is a deterministic walk down a list you can print:
sys.modulesfirst, thensys.pathtop to bottom, and the first directory that can produce the name wins — which is the entire reason arandom.pyof yours, sitting at index 0 because that is where your script lives, beats a standard library that never even gets asked. sys.modulesis an ordinary dict, so the secondimport jsonis one lookup and no work at all — there is one module object per name per process, its top-level code runs exactly once no matter how many files ask for it, and everyone who imported it holds a reference to that same object, which is why evicting the key withdel sys.modules[name]is what forces the next import to find, compile and exec again —importlib.reloadbeing the surgical alternative, which re-runs the search but re-execs into the same object.- Behind the keyword sit three ordinary calls —
find_spec→module_from_spec→exec_module: a finder answers where and how by filling in aModuleSpec, a loader answers create and exec, and the still-empty module is registered in the cache before one line of it runs — which is precisely what turns a circular import from an infinite loop into a half-built module you can see in a traceback. - Compiling is a stage, not a build step: text → tokens → AST → code object, performed per file at the moment that file's import line executes, and the
.pyccaches only that last output behind a sixteen-byte header — magic, flags, and then either a source timestamp the clock can lie about or a hash of the source that it cannot. - Every part of the machine is an object on a list you are allowed to edit —
sys.path,sys.path_hooks,sys.meta_path— which is why one logical package can span two separately installed folders with no__init__.pybetween them, why a.zipon the path simply works, and why a finder of your own can hand back a module whose source never touched a disk.
LOAD_FAST at the end of compile() is a homecoming rather than a new fact; chapter 2 gave you "everything is an object", which is why a module turns out to be one more object with a __dict__ that exec_module simply fills; chapter 3 gave you the binding, so sys.modules['beat'] is beat reads as a fact about names rather than a coincidence; chapter 7 gave you the hash table that the cache lookup literally is, which is why a repeat import costs what a dict costs; chapter 13 gave you the except ImportError that turns an optional dependency into a fallback instead of a stopped program; chapter 15 gave you the read that copies bytes across the ring boundary, which is all a .pyc is until marshal touches it; chapter 18 gave you this chapter's only real claim in its first form — what crosses into your process is bytes, and something on the near side has to rebuild the live thing; and chapter 19 gave you the four verbs, find, compile, exec, cache, named honestly and still half-trusted. Chapter 20 added no fifth verb. What it added is that every one of the four is a call you can make yourself, on a list you can print — so the next time an import does something you did not expect, you are not guessing at a keyword's mood. You are reading a machine, and it will tell you.Chapter 20 pops the hood on the import machine and watches every stage run: the search down sys.path, the sys.modules cache that runs a module body exactly once, the ModuleSpec receipt with its finder and loader, the 16-byte .pyc header, and the tokenize -> ast -> compile pipeline that turns your source text into a runnable code object. Every program writes its own files into a scratch directory and prints only facts you can predict, so the output is the same on every run.