python/volume-2/chapter-20ch 20 / 64 · 15 min

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 chapter we pry the lid off and watch each one actually run. Here is the plan. First we print sys.path, the ordered list of folders import walks, and 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 other side as a live module object. The whole way through, we keep asking the one question 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: 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.

sys.path: an ordered search, first match wins Finders and loaders: the import protocol Source becomes bytecode; __pycache__ caches it The compile pipeline: source → AST → code object
scroll to begin

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. You run your real program and it breaks: 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 same code that works on your laptop will break the moment it runs in a directory that does not 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 moment 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. At each location it asks 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:

a live sessionpython
>>> 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 land

Read 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 is checked before everything else, which 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 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 lands. Top to bottom, first match wins, no exceptions.

import random1 · check the cachesys.modules — in-memory cache'sys' → module'os' → modulehit = done · miss = search sys.path ↓2 · cache miss — walk sys.path in order, first match wins[0] ./ your project folderholds random.py → MATCH · search STOPS here✓ found[1] PYTHONPATH entries[2] stdlib …/python3.12/the real random.py lives here · never reached[3] site-packages/ (pip installs)shadowedby [0]
Fig — Import resolution checks the sys.modules cache first; on a miss it walks sys.path top-down and stops at the first directory holding the module — so a local file can shadow the standard library.

The most useful 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 and 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; 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.

shadowing, revealedpython
>>> 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 stdlib
NOT EVERYTHING IS A FILE
A handful of modules — sys 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.
THE ONE IDEA TO CARRY FORWARD
Import is one loop: check 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 the question "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 — "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:

the protocol is made of objectspython
>>> 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.

sys.meta_path — finders tried in order; each returns a ModuleSpec or None (pass to next)import XBuiltinImporterNone →FrozenImporterNone →PathFinderinner: sys.path_hooksFileFinder-hookzipimporter-hookemits a spec →ModuleSpecname = 'X'loader = SourceFileLoaderorigin = /pkg/X.pycached = /pkg/X.pycsubmodule_search_locations = Noneloader — runs three steps1 module_from_spec(spec)2 sys.modules['X'] = m (pre-exec)3 loader.exec_module(m)→ fully-initialised module ✓
Fig — Behind import X the sys.meta_path finders are tried in turn; the first to recognise the name returns a ModuleSpec, and the loader builds the module, registers it in sys.modules before executing it, then runs its body.

Phase LOAD is where the spec becomes a living thing. Import calls importlib.util.module_from_spec(spec) to make an empty module object, then inserts it 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 clean: a finder answers "where and how," a loader does "create and exec." Nothing is hidden. You can perform the entire sequence by hand:

import_by_hand.pypython
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, another handles zip archives. It then memoises the result in sys.path_importer_cache, so it only computes it 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 ONE IDEA TO CARRY FORWARD
Import is not a statement, it is three calls: find_specmodule_from_specexec_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 noticeable pause; the second time it is instant. And sitting next to your code, unasked for, 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 explained startup time, a whole family of "stale bytecode" bugs, and exactly what Python is trading memory for speed on.

One import statement, and the avalanche it drags ininteractive
one import statement → the transitive pile it really loads YOU TYPED import pandas ≈600 modules whose top-level code just ran cold import ≈ 350 ms you asked for a dataframe; the whole library wing arrived modules loaded · log scale · 1 → 2,000 1 10 100 1,000 One import, hundreds of modules’ top-level code executed — that is the cold-start tax. sys.modules makes the 2nd import a dict lookup; the .pyc spares the recompile — neither shrinks the pile.
import pandas
Slide from import sys to import tensorflow. The number is not files on 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.
Fig — 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 script you run directly as __main__ is the exception: 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: 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); a 4-byte bit-field of flags; and then, in the default timestamp scheme, the source's 4-byte modification time plus its 4-byte size.

a .pyc file — 16-byte header, then the compiled code objectmagic4 Bflags4 Bsource mtime4 Bsize4 Bmarshalled code object↑ compared to the .py's mtime — the stale-cache trip-wirefirst import — no fresh .pyc: run the whole pipelineread .pytokenizeparsecompilemarshalwrite .pycexec ✓second import — .pyc fresh: header check, then loadread .pyc headermagic ✓ · mtime==stat ✓tokenize · parse ·compile · write — SKIPPEDmarshal.loadexec ✓
Fig — A .pyc caches the compiled code object; when its magic number and stored source-mtime still match the file on disk, the second import skips tokenising, parsing and compiling and just un-marshals the bytecode.

Now the payoff. On the next import the loader reads only that header. It compares the magic number: a mismatch means a different interpreter, so it recompiles. 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:

read_pyc_header.pypython
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 trusted

Be 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: it runs code that no longer matches the source you are reading. That is exactly 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).

✗ 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.

TURNING THE CACHE OFF
Set 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 look.

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:

tokenize_demo.pypython
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: 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:

ast_demo.pypython
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:

disassemble.pypython
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 caller
1 · SOURCEtotal = a + b2 · TOKENSNAMEtotalOP=NAMEaOP+NAMEb3 · ASTAssignName 'total'BinOp (Add)aAddb4 · CODE OBJECTco_varnames = ('total','a','b')co_consts = ()co_code:LOAD_FAST aLOAD_FAST bBINARY_OP +STORE_FAST totalVolume-1 VM — a stack machine executes co_code (a=3, b=4)34← topLOAD_FAST a → push 3LOAD_FAST b → push 4BINARY_OP + → pop 4,3 · push 7STORE_FAST total → pop 7 → total=7
Fig — Compilation is an assembly line — source to tokens to AST to code object — and the code object's co_code is the exact opcode sequence the Volume-1 stack VM later runs.

And 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:

the whole pipeline in one callpython
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 again
THE ONE IDEA TO CARRY FORWARD
There is a compiler in the room. Text → tokens → AST → code object → bytecode, and the bytecode is the very stack machine you built in Volume 1. ast 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, with 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 exact algorithm PathFinder runs while scanning for company. If an entry has company/__init__.py, that is a regular package, so 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 whose __path__ is not a plain list but a live _NamespacePath object holding all the collected portions.

one name, two directoriestext
dirA/company/billing.py      # no __init__.py anywhere under company/
dirB/company/auth.py         # a second, separate portion of the same name
the merged path is the magicpython
>>> 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, merged

That 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.

namespace package — no __init__.py, fused from many folderscompany__path__ = [ dirA/company , dirB/company ](no __init__.py in either portion)__path__ portions ↓dirA/ (sys.path[0])company/— no __init__.pybilling/dirB/ (sys.path[3])company/— no __init__.pyauth/regular package — contrastshop/__init__.pycart.pyorders.py__path__ = ['…/shop']one folder · one portionsubmodule search (import company.billing) walks EVERY portion in __path__
Fig — A namespace package has no __init__.py: Python fuses same-named folders found on several sys.path entries into one module whose __path__ lists every contributing portion.

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 real package — created by a botched checkout, or left behind as a stray build artefact — can silently convert a working regular import into an empty namespace package and shadow the real one. The tell is always the same pair of fields:

how to tell them apart at runtimepython
>>> 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 _NamespacePath
AN EMPTY FOLDER CAN SHADOW A REAL PACKAGE
If import mypkg suddenly fails deep inside with a missing submodule, check mypkg.__file__. If it is None, a stray directory named mypkg/ earlier on sys.path has turned your installed regular package into an empty namespace package — the classic "it imports but has nothing in it" bug. Delete the impostor folder or fix the path order.

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, and how encrypted or remote code gets fetched and run. 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:

memory_importer.pypython
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 memory

Notice 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.

sys.modules— cache · hit? return it, donechecked firstmisssys.meta_path — consulted first, on every importCustomFinderyours ▸ tried firstBuiltinImporterFrozenImporterPathFinderexpands ↓sys.path_importer_cache (memo)'/proj' → FileFinder'app.zip' → zipimporter'https://…' → YourFindersys.path_hooks — per sys.path entryFileFinderzipimporteryourhookCustomFinder emits ▾ModuleSpecname = 'mod'loader = URLLoaderloaderexec_module()runs the fetched sourceremoteGET …/mod.pysource →
Fig — The whole import machinery top to bottom: sys.modules, then the sys.meta_path finders (one of them PathFinder, delegating to per-entry sys.path_hooks) — any of which can be your own finder that loads a module straight from a URL.

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 exactly 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 magic:

importing out of a zippython
>>> 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 it

Hold the precedence in your head as one ordered pipeline. The sys.modules cache comes first. Then 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. The machinery, not your loader, handles inserting into sys.modules and unwinding on error.

A HOOK SEES EVERY IMPORT — SO IT IS AN ATTACK SURFACE
Because a meta path finder is consulted for every import and can return arbitrary code, tampering with 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.
THE ONE IDEA TO CARRY FORWARD
Import looked like a keyword; it is a documented protocol with a default filesystem implementation you can inspect, extend, or replace — no C required. A finder answers where-and-how, a loader creates-and-execs, and both are objects on lists you are free to edit. From the file descriptor to the pickle pipe, this whole volume is machines that rebuild live objects from bytes — and import, it turns out, is one of them, visible all the way from the metal up.

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 →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 20, in working code

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.

The search and the cache — sys.path and sys.modules
Import is FIND then CACHE. These three show that the path order decides which file wins, and the cache guarantees a single module object whose body runs exactly once.
The spec machinery — finders, loaders, ModuleSpec
Behind every import is a spec (the receipt), a finder (locates it), and a loader (runs it). Here we read a real spec, redo import by hand in three calls, and finally serve a module straight from a Python string with no file at all.
The compiled cache — inside a .pyc
A .pyc is a 16-byte header in front of marshalled bytecode. We watch import write one, then decode magic / flags / mtime by hand, and contrast timestamp validation against hash validation.
Source to code object — tokenize, ast, compile
Before a line runs, text becomes tokens, tokens become a syntax tree, and the tree compiles to a code object. Each stage is an ordinary Python object you can read — and even rewrite.
end of chapter 20 · six sections
iolinked.com
Written by Ajai Raj