🎯 Learning Objectives
- Trace the full import machinery:
importstatement →__import__→importlib._bootstrap - Understand
sys.modulesas the module cache and how to exploit it - Explain the Finder/Loader protocol:
sys.meta_path,sys.path_hooks,sys.path - Write a custom
MetaPathFinderandLoaderto import from non-standard sources - Understand namespace packages (PEP 420) and implicit namespace packages
- Use
importlib.import_module,importlib.reload, andimportlib.util - Implement import-time transformations (source transpiling, code injection)
1 — What Happens When You Write import foo
Every import statement eventually calls one function:
builtins.__import__. From there, control passes into
importlib._bootstrap, a pure-Python module that is frozen
into the interpreter at build time. Understanding this pipeline lets you
intercept, redirect, or replace any part of it.
import foo
↓
builtins.__import__("foo", globals, locals, fromlist=[], level=0)
↓
importlib._bootstrap._find_and_load("foo", __import__)
↓
1. Check sys.modules["foo"] — if present, return cached module immediately
(This is why circular imports can work — partial modules are cached)
2. Find the module using sys.meta_path finders (in order):
a. BuiltinImporter — checks if "foo" is a built-in C module (e.g. sys, builtins)
b. FrozenImporter — checks if "foo" is a frozen module
c. PathFinder — searches sys.path entries using sys.path_hooks
3. Load the module using the finder's associated Loader:
a. Create a ModuleSpec (name, origin, submodule_search_locations)
b. Create an empty module object and ADD IT TO sys.modules IMMEDIATELY
(allows circular imports to see a partial module)
c. Execute the module's source/bytecode in the module's __dict__
d. Return the fully-populated module
4. Handle ImportError if no finder claims the module
import sys
# sys.modules is the cache
import os
print("os" in sys.modules) # True — already loaded
print(sys.modules["os"] is os) # True — same object
# Import resolves to the cached module — zero I/O on repeat imports
import os as os2
print(os is os2) # True — same module object
# sys.meta_path — the list of finders Python tries in order
for finder in sys.meta_path:
print(type(finder).__name__)
# BuiltinImporter
# FrozenImporter
# PathFinder
# sys.path — where PathFinder looks for packages/modules
import pprint
pprint.pprint(sys.path)
import_pipeline.pysys.modules:
Adding a module to sys.modules before fully loading it is
the key mechanism that allows circular imports to work at all. If module A imports
module B, and B imports A, Python finds A's partial module in sys.modules
and uses it rather than re-entering A's loading code. This is why the order of
definitions in a module matters when circular imports exist.
2 — sys.modules Manipulation
sys.modules is an ordinary dictionary. You can read it, add
to it, replace entries, and delete from it. This is the foundation of lazy
loading libraries, mock patching, and plugin systems that need to override
a module's public API.
import sys
# ── Inspect ──
print(len(sys.modules)) # hundreds of modules already loaded at startup
# Find all packages (modules with __path__)
packages = {k for k, v in sys.modules.items()
if hasattr(v, "__path__")}
print(sorted(packages)[:10])
# ── Force reload ──
import importlib
import mymodule # noqa (hypothetical)
importlib.reload(mymodule) # re-executes the module; existing refs still point to old objects
# ── Module substitution (advanced) ──
# Replace a module in sys.modules with a custom object
# Used by mock libraries, lazy import systems
class LazyModule:
"""A placeholder that loads the real module on first attribute access."""
def __init__(self, name: str):
object.__setattr__(self, "_name", name)
object.__setattr__(self, "_module", None)
def _load(self):
if object.__getattribute__(self, "_module") is None:
name = object.__getattribute__(self, "_name")
import importlib
module = importlib.import_module(name)
object.__setattr__(self, "_module", module)
sys.modules[name] = module # replace ourselves in the cache
return object.__getattribute__(self, "_module")
def __getattr__(self, attr):
return getattr(self._load(), attr)
# Register a lazy placeholder
sys.modules["json"] = LazyModule("json")
import json # returns the LazyModule (no real load yet)
data = json.loads('{"x":1}') # NOW loads — _load() called on first attribute access
print(data) # {'x': 1}
sys_modules_manipulation.pysys.modules surgery:
Manipulating sys.modules directly is powerful but fragile.
Removing or replacing entries can confuse isinstance checks
and identity comparisons —
isinstance(obj, sys.modules["mymodule"].MyClass) may fail if
the module was reloaded and created a new class object. Always prefer
importlib.reload() over manual removal.
3 — The Finder / Loader Protocol
The import system uses two cooperating objects:
- Finder — implements
find_spec(fullname, path, target=None) -> ModuleSpec | None - Loader — implements
create_module(spec)andexec_module(module)
A ModuleSpec is the bridge between them: the finder creates it and attaches the loader to it. The bootstrap machinery then drives the loader through the two-phase creation protocol.
import importlib.util, importlib.machinery
# Inspect a real module's spec
spec = importlib.util.find_spec("json")
print(spec.name) # "json"
print(spec.origin) # ".../json/__init__.py"
print(spec.submodule_search_locations) # [".../json"]
# Loader attached to the spec
print(type(spec.loader)) # <class 'importlib.machinery.SourceFileLoader'>
print(spec.loader.path) # ".../json/__init__.py"
# Manual load via spec
import sys
spec2 = importlib.util.spec_from_file_location("mymod", "/path/to/mymod.py")
module = importlib.util.module_from_spec(spec2)
sys.modules["mymod"] = module
spec2.loader.exec_module(module)
print(module)
inspect_spec.pyPython ships with several built-in loader types for different file kinds:
| Loader | Handles |
|---|---|
SourceFileLoader | .py files |
SourcelessFileLoader | .pyc files (no source) |
ExtensionFileLoader | .so / .pyd C extension modules |
NamespaceLoader | Namespace packages (no __init__.py) |
BuiltinImporter | Built-in C modules (sys, builtins) |
FrozenImporter | Modules compiled into the interpreter |
4 — Writing a Custom MetaPathFinder and Loader
The most direct extension point is sys.meta_path. Insert an
object whose find_spec method claims certain module names and
returns a spec with your own loader. The example below imports modules
whose source lives entirely in a Python dictionary — no files needed.
import sys
import importlib.abc
import importlib.machinery
import importlib.util
import types
# In-memory "package": name → source code
_VIRTUAL_MODULES = {
"virtual.greet": """
def hello(name: str) -> str:
return f"Hello, {name}! (from virtual module)"
""",
"virtual.math_utils": """
def add(a, b): return a + b
def mul(a, b): return a * b
PI = 3.14159265358979
""",
}
class VirtualFinder(importlib.abc.MetaPathFinder):
"""Finds modules whose source is stored in _VIRTUAL_MODULES."""
def find_spec(self, fullname, path, target=None):
if fullname in _VIRTUAL_MODULES:
source = _VIRTUAL_MODULES[fullname]
loader = VirtualLoader(fullname, source)
return importlib.machinery.ModuleSpec(
name=fullname,
loader=loader,
origin=f"<virtual:{fullname}>",
)
return None # not ours — let other finders try
class VirtualLoader(importlib.abc.Loader):
"""Loads a module from an in-memory source string."""
def __init__(self, fullname: str, source: str):
self.fullname = fullname
self.source = source
def create_module(self, spec):
return None # use default module creation
def exec_module(self, module):
code = compile(self.source, f"<virtual:{self.fullname}>", "exec")
exec(code, module.__dict__)
# Install our finder at the FRONT of sys.meta_path
sys.meta_path.insert(0, VirtualFinder())
# Now import as normal
import virtual.greet as greet
print(greet.hello("World")) # Hello, World! (from virtual module)
import virtual.math_utils as mu
print(mu.add(3, 4)) # 7
print(mu.PI) # 3.14159265358979
# Cleanup
sys.meta_path.pop(0)
virtual_finder.py
The two-phase loader protocol separates object creation
(create_module) from population
(exec_module). Returning None from
create_module tells the bootstrap to create a standard
types.ModuleType object. This lets you focus
exec_module solely on running the code.
5 — sys.path_hooks and PathEntryFinder
sys.meta_path's PathFinder delegates to a second
hook layer. For each entry in sys.path it calls every callable
in sys.path_hooks, passing the path entry string. The first
hook that returns a PathEntryFinder wins; hooks signal
"not mine" by raising ImportError. Results are cached in
sys.path_importer_cache.
import sys
import importlib.abc
import importlib.machinery
import zipfile
import io
class ZipStringFinder(importlib.abc.PathEntryFinder):
"""
A PathEntryFinder that imports modules from an in-memory zip archive.
Installed via a sys.path_hooks entry.
"""
_archives: dict[str, bytes] = {} # path_entry → zip bytes
def __init__(self, path_entry: str):
if path_entry not in self._archives:
raise ImportError(f"Not a virtual zip: {path_entry}")
self._zip = zipfile.ZipFile(io.BytesIO(self._archives[path_entry]))
def find_spec(self, fullname, target=None):
module_path = fullname.replace(".", "/") + ".py"
try:
source = self._zip.read(module_path).decode()
except KeyError:
return None
loader = _ZipStringLoader(fullname, source)
return importlib.machinery.ModuleSpec(fullname, loader)
class _ZipStringLoader(importlib.abc.Loader):
def __init__(self, fullname, source):
self.fullname = fullname
self.source = source
def create_module(self, spec): return None
def exec_module(self, module):
exec(compile(self.source, f"<zip:{self.fullname}>", "exec"), module.__dict__)
def zip_path_hook(path_entry: str):
"""Called for each entry in sys.path; raises ImportError if we don't handle it."""
return ZipStringFinder(path_entry)
# Install the hook
sys.path_hooks.insert(0, zip_path_hook)
# Create a fake in-memory zip archive with a module inside
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("zipped_utils.py", "def double(x): return x * 2\n")
ZipStringFinder._archives["virtual_zip://myarchive"] = buf.getvalue()
# Add our virtual path entry
sys.path.insert(0, "virtual_zip://myarchive")
sys.path_importer_cache.clear() # clear cached PathFinder decisions
import zipped_utils
print(zipped_utils.double(21)) # 42
zip_path_hook.pyzipimport works:
This is exactly how Python's built-in zipimport operates —
a zipimporter hook handles real .zip files and
.egg files found on sys.path.
sys.path_importer_cache caches the PathEntryFinder
for each path entry so the hooks are not re-evaluated on every import.
6 — Namespace Packages (PEP 420)
A namespace package is a package spread across multiple
directories — each contributing sub-modules — with no __init__.py
in any of them. Python 3.3+ (PEP 420) supports this natively. When
PathFinder walks sys.path and finds a directory
matching the package name but lacking __init__.py, it does
not stop; instead it collects all such directories into a
_NamespacePath and returns a namespace package whose
__path__ spans all of them.
# Directory structure (two separate directories on sys.path): /path/A/mypkg/module_a.py /path/B/mypkg/module_b.py # Both contribute to the same 'mypkg' namespace package # sys.path = ["/path/A", "/path/B"] import mypkg.module_a # found in /path/A import mypkg.module_b # found in /path/B
import sys, types, importlib.util
# Inspect a namespace package
spec = importlib.util.find_spec("mypkg") # assuming it exists
if spec and spec.submodule_search_locations:
print(f"Namespace package paths: {list(spec.submodule_search_locations)}")
# ['/path/A/mypkg', '/path/B/mypkg']
# Create a synthetic namespace package programmatically
ns_pkg = types.ModuleType("synthetic_ns")
ns_pkg.__path__ = ["/some/path/a", "/some/path/b"] # no __file__
ns_pkg.__package__ = "synthetic_ns"
ns_pkg.__spec__ = None
sys.modules["synthetic_ns"] = ns_pkg
print(ns_pkg) # <module 'synthetic_ns' (namespace)>
namespace_packages.pyThe key difference between a regular package and a namespace package:
-
Regular package — has
__init__.py; a single directory is its__path__; loading stops as soon as one matching directory with__init__.pyis found. -
Namespace package — no
__init__.py;__path__is a_NamespacePaththat can span multiple directories; Python scans all ofsys.pathbefore deciding.
google-cloud-storage and
google-cloud-bigquery both install into the
google.cloud namespace package without conflicting.
7 — Import-Time Code Transformation
Because exec_module receives control before any code runs,
you can intercept the source text, parse it into an AST, rewrite the AST,
and compile the modified tree. This enables source transpilation, automatic
instrumentation, and custom language extensions — all triggered by a plain
import statement.
import sys
import importlib.abc
import importlib.machinery
import importlib.util
import ast
import textwrap
class LoggingTransformer(ast.NodeTransformer):
"""
AST transformer: inject a print statement at the start of every function.
Demonstrates import-time code transformation.
"""
def visit_FunctionDef(self, node):
self.generic_visit(node) # recurse into nested functions
log_stmt = ast.parse(
f'print(f"CALL: {node.name}()")',
mode="single",
).body[0]
ast.copy_location(log_stmt, node)
node.body.insert(0, log_stmt)
return node
visit_AsyncFunctionDef = visit_FunctionDef
class TransformingLoader(importlib.abc.SourceLoader):
"""Applies LoggingTransformer to any module we load."""
def __init__(self, fullname: str, path: str):
self.fullname = fullname
self.path = path
def get_data(self, path: bytes | str) -> bytes:
with open(path, "rb") as f:
return f.read()
def get_filename(self, fullname: str) -> str:
return self.path
def source_to_code(self, data, path, *, _optimize=-1):
source = data.decode("utf-8") if isinstance(data, bytes) else data
tree = ast.parse(source, path)
tree = LoggingTransformer().visit(tree)
ast.fix_missing_locations(tree)
return compile(tree, path, "exec", optimize=_optimize)
class TransformingFinder(importlib.abc.MetaPathFinder):
"""Intercepts imports of modules whose names end with '_debug'."""
def find_spec(self, fullname, path, target=None):
if not fullname.endswith("_debug"):
return None
# Strip the _debug suffix to find the real file
real_name = fullname[:-6] # e.g. "utils_debug" → "utils"
real_spec = importlib.util.find_spec(real_name)
if real_spec is None or real_spec.origin is None:
return None
loader = TransformingLoader(fullname, real_spec.origin)
return importlib.machinery.ModuleSpec(fullname, loader, origin=real_spec.origin)
# Install the finder
sys.meta_path.insert(0, TransformingFinder())
# Now: import utils_debug loads utils.py with logging injected
# import utils_debug # every function will print its name when called
transforming_loader.py
importlib.abc.SourceLoader is a convenient base class: it
handles .pyc cache reading/writing automatically, and exposes
source_to_code as the single override point for changing how
source is compiled. The bootstrap calls
get_data(get_filename(fullname)) to fetch bytes, then
source_to_code to compile them.
ast.fix_missing_locations; they do not automatically
invalidate .pyc caches (you need a custom cache key or
must disable bytecode caching); and they make debugging significantly
harder. Documented production uses include coverage instrumentation
(coverage.py), contract checking (PyContracts), and dialect extensions
(macropy). Always document transformations prominently.
Relative Imports & Package __init__
Relative imports use dot notation to navigate the package hierarchy. Understanding exactly how Python resolves them demystifies many import errors.
# Package structure:
# myapp/
# __init__.py
# utils.py
# api/
# __init__.py
# routes.py
# auth.py
# Inside myapp/api/routes.py:
from .auth import verify_token # sibling: myapp.api.auth
from ..utils import format_response # parent: myapp.utils
from . import auth # import sibling module object
# NEVER use relative imports in scripts run directly (not as packages)
# python myapp/api/routes.py → ImportError: attempted relative import with no known parent package
# python -m myapp.api.routes → works, because -m sets __package__ correctly
# __package__ and __name__ control relative import resolution
import myapp.api.routes as r
print(r.__name__) # "myapp.api.routes"
print(r.__package__) # "myapp.api" ← used to resolve relative dots
relative_imports.py
What __init__.py Controls
# myapp/__init__.py — controls what `import myapp` exposes
# 1. Eager re-export: make sub-module symbols available at package level
from .utils import format_response, parse_config # now: from myapp import format_response
# 2. Lazy sub-package loading: only import when needed
def get_api():
from .api import routes # imported on first call, not at package load time
return routes
# 3. __all__: controls `from myapp import *`
__all__ = ["format_response", "parse_config"]
# 4. Version
__version__ = "2.1.0"
# 5. Prevent direct sub-module access before __init__ runs
# (sub-modules are NOT automatically accessible as attributes of the package
# unless they are imported in __init__.py or accessed with dot notation first)
import myapp
# myapp.utils is None / AttributeError ← unless explicitly imported in __init__.py
import myapp.utils
# NOW myapp.utils is accessible — importing a sub-module always sets it as an attribute
package_init.py
import myapp does NOT automatically make
myapp.utils accessible. You must either import myapp.utils
explicitly, or import it inside myapp/__init__.py. Many packages
import their public API in __init__.py so users can write
from myapp import MyClass instead of
from myapp.internals.impl import MyClass.
Circular Imports: Causes, Detection & Fixes
Circular imports are one of the most confusing Python runtime errors.
Understanding how sys.modules handles partial modules
explains both why they sometimes work and why they sometimes fail.
# ── Why circular imports fail ──
# a.py: from b import B
# b.py: from a import A ← A not yet defined when b is executing
# The sequence:
# 1. import a
# 2. a.py starts executing: "from b import B"
# 3. b.py starts executing: "from a import A"
# 4. sys.modules["a"] exists (partial!) but A is not yet defined in it
# 5. ImportError: cannot import name 'A' from partially initialized module 'a'
# ── Fix 1: Import at function level (deferred import) ──
# a.py
class A:
def get_b(self):
from b import B # imported when the method is called, not at module load
return B()
# ── Fix 2: Import the module, not the name ──
# a.py
import b # just the module — works because sys.modules["b"] is added immediately
class A:
def get_b(self): return b.B() # b.B is resolved at call time
# ── Fix 3: Restructure — extract shared code to a third module ──
# common.py: shared types / interfaces
# a.py: import from common
# b.py: import from common
# → no cycle
# ── Detecting circular imports ──
import sys
def find_import_chain(target: str) -> list:
"""Return the import chain leading to target (very simplified)."""
return [name for name in sys.modules if target in name]
# importlib.util.find_spec raises ImportError on circular deps
# Use modulefinder for static analysis:
from modulefinder import ModuleFinder
finder = ModuleFinder()
finder.run_script("main.py") # analyses all imports
for name, mod in finder.modules.items():
if mod.__file__:
print(f" {name}: {mod.__file__}")
circular.py
Useful importlib.util Recipes
import importlib.util
import sys
# ── 1. Import a module from an arbitrary file path ──
def import_from_path(module_name: str, file_path: str):
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module # register before exec so circular imports work
spec.loader.exec_module(module)
return module
plugin = import_from_path("my_plugin", "/path/to/plugin.py")
# ── 2. Check if a module is importable without importing it ──
def is_importable(name: str) -> bool:
return importlib.util.find_spec(name) is not None
print(is_importable("json")) # True
print(is_importable("nonexist")) # False
# ── 3. Get the file path of an installed module ──
spec = importlib.util.find_spec("numpy")
if spec:
print(spec.origin) # /path/to/numpy/__init__.py
# ── 4. Conditional import with a fallback ──
try:
import ujson as json_lib
except ImportError:
import json as json_lib
# Or using importlib:
def import_first_available(*names: str):
for name in names:
if importlib.util.find_spec(name):
return importlib.import_module(name)
raise ImportError(f"None of {names} could be imported")
json_lib = import_first_available("ujson", "orjson", "json")
# ── 5. Reload with dependency tracking ──
import importlib, sys
def deep_reload(module_name: str, prefix: str) -> None:
"""Reload a module and all sub-modules sharing the given prefix."""
to_reload = [
name for name in list(sys.modules)
if name == module_name or name.startswith(prefix + ".")
]
for name in sorted(to_reload, reverse=True): # children before parents
mod = sys.modules.pop(name, None)
if mod:
importlib.import_module(name) # re-import into fresh sys.modules entry
importlib_recipes.py
Best Practices
- Never mutate
sys.modulesin production application code — it is a global mutable cache shared across all threads. Reserve manipulation for test infrastructure, hot-reload systems, and import hooks. - Install custom finders at the end of
sys.meta_pathunless you need to override built-in behaviour — inserting at position 0 means your finder is tried before the standard finders, which can slow down every import. - Fix circular imports at the architecture level — deferred imports (import inside a function) are a valid workaround, but they signal a design problem. The real fix is extracting shared code into a third module or restructuring the dependency graph.
- Use
importlib.util.find_spec()to check availability without side effects — it does not execute the module and does not add it tosys.modules. - Always register the module in
sys.modulesbefore callingexec_module()when writing custom loaders — this prevents infinite recursion when the loaded module imports itself or a sibling. - Prefer namespace packages over
pkgutil-style packages for split distributions — PEP 420 namespace packages require no__init__.pyand work transparently with pip install. - Use
importlib.reload()cautiously — it re-executes the module but does NOT update existing references to old class/function objects. Use it only in development REPLs and hot-reload frameworks that know how to update all references. - Clear
sys.path_importer_cacheafter adding newsys.pathentries —PathFindercaches decisions per path entry, so new entries are not automatically scanned until the cache is cleared.
Exercises
Exercise 1 — Database-Backed Module Finder
Build a MetaPathFinder that loads Python source code stored
in a SQLite database:
- Create a SQLite table
modules(name TEXT PRIMARY KEY, source TEXT)and insert two modules:db.greetanddb.math_ops. - Implement
DBFinderandDBLoader— the loader queries the database for the source and compiles/executes it. - Register the finder and verify that
import db.greetworks. - Add a caching layer: after first load, store the compiled bytecode in a separate
module_cache(name, bytecode)table and skip re-compilation on subsequent imports.
💡 Hint
import sqlite3, sys, types, importlib.abc, importlib.machinery
DB_PATH = "modules.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("CREATE TABLE IF NOT EXISTS modules(name TEXT PRIMARY KEY, source TEXT)")
conn.execute("INSERT OR REPLACE INTO modules VALUES ('db.greet', 'def hello(n): return f\"Hi {n}\"')")
conn.execute("INSERT OR REPLACE INTO modules VALUES ('db.math_ops', 'add = lambda a,b: a+b')")
conn.commit(); conn.close()
class DBLoader(importlib.abc.Loader):
def __init__(self, name, source):
self.name, self.source = name, source
def create_module(self, spec): return None
def exec_module(self, module):
exec(compile(self.source, f"", "exec"), module.__dict__)
class DBFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
conn = sqlite3.connect(DB_PATH)
row = conn.execute("SELECT source FROM modules WHERE name=?", (fullname,)).fetchone()
conn.close()
if row:
return importlib.machinery.ModuleSpec(fullname, DBLoader(fullname, row[0]))
init_db()
sys.meta_path.append(DBFinder())
import db.greet
print(db.greet.hello("World")) # Hi World
Exercise 2 — Import Hook for Type-Annotated Config Files
Build an import hook that allows importing .cfg (INI-style)
files as Python modules, automatically converting sections into dataclasses:
- A
.cfgfile likeapp.cfgwith sections[database]and[server]should be importable asimport app_cfg. - Each section becomes a
SimpleNamespaceattribute on the module. - The finder recognises modules ending in
_cfgand maps them to a.cfgfile onsys.path. - Write a test: create a real
test_app.cfgfile, import it astest_app_cfg, and accesstest_app_cfg.database.host.
💡 Hint
import sys, configparser, importlib.abc, importlib.machinery
from types import SimpleNamespace, ModuleType
class CfgLoader(importlib.abc.Loader):
def __init__(self, path): self.path = path
def create_module(self, spec): return None
def exec_module(self, module):
cfg = configparser.ConfigParser()
cfg.read(self.path)
for section in cfg.sections():
setattr(module, section, SimpleNamespace(**dict(cfg[section])))
class CfgFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if not fullname.endswith("_cfg"): return None
filename = fullname[:-4] + ".cfg" # strip _cfg, add .cfg
for directory in sys.path:
import os
candidate = os.path.join(directory, filename)
if os.path.exists(candidate):
return importlib.machinery.ModuleSpec(fullname, CfgLoader(candidate))
sys.meta_path.append(CfgFinder())
# With test_app.cfg containing [database] host=localhost
# import test_app_cfg; print(test_app_cfg.database.host) # localhost
Exercise 3 — Import Auditor
Build an import auditor that records every module import with timing and dependency information during an application's startup:
- Implement an
AuditFinderthat intercepts everyfind_speccall, records(timestamp, module_name, importer_module), and then delegates to the real finders. - After startup, print a report: top 10 slowest modules to import (by time from first
find_specto completion), total module count, and the full import chain for a specified module. - Install and uninstall cleanly using a context manager.
- Test by wrapping
import flask(or any available package) inside the context manager.
💡 Hint
import sys, time, importlib.abc
from contextlib import contextmanager
from collections import defaultdict
class ImportAuditor(importlib.abc.MetaPathFinder):
def __init__(self):
self.log: list[tuple] = [] # (t, name, caller)
self.timings: dict[str, float] = {}
self._start: dict[str, float] = {}
def find_spec(self, fullname, path, target=None):
t = time.perf_counter()
caller = sys._getframe(1).f_globals.get("__name__", "")
self.log.append((t, fullname, caller))
self._start.setdefault(fullname, t)
# Delegate to real finders
for finder in sys.meta_path:
if finder is self: continue
spec = finder.find_spec(fullname, path, target)
if spec:
self.timings[fullname] = time.perf_counter() - self._start[fullname]
return spec
return None
def report(self, top_n=10):
print(f"\n=== Import Audit: {len(self.log)} imports ===")
slowest = sorted(self.timings.items(), key=lambda x: -x[1])[:top_n]
for name, t in slowest:
print(f" {t*1000:6.1f}ms {name}")
@contextmanager
def audit_imports():
auditor = ImportAuditor()
sys.meta_path.insert(0, auditor)
try:
yield auditor
finally:
sys.meta_path.remove(auditor)
with audit_imports() as a:
import json, pathlib, dataclasses
a.report()