🟠 Python Internals

The Import System: importlib, Finders & Loaders

📖 Lesson 47 ⏱ 60 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Trace the full import machinery: import statement → __import__importlib._bootstrap
  • Understand sys.modules as the module cache and how to exploit it
  • Explain the Finder/Loader protocol: sys.meta_path, sys.path_hooks, sys.path
  • Write a custom MetaPathFinder and Loader to import from non-standard sources
  • Understand namespace packages (PEP 420) and implicit namespace packages
  • Use importlib.import_module, importlib.reload, and importlib.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.py
Circular imports and sys.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.py
Warning — sys.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) and exec_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.py

Python 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
NamespaceLoaderNamespace packages (no __init__.py)
BuiltinImporterBuilt-in C modules (sys, builtins)
FrozenImporterModules 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.py
How zipimport 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.py

The 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__.py is found.
  • Namespace package — no __init__.py; __path__ is a _NamespacePath that can span multiple directories; Python scans all of sys.path before deciding.
Real-world use: Namespace packages are the standard way to split a large library across multiple distribution packages that all install into the same logical namespace. For example, 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.

Production caveats for AST transforms: Import-time AST transformations come with serious trade-offs: they can break source-line numbers in tracebacks unless you call 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
A common misconception: 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.modules in 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_path unless 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 to sys.modules.
  • Always register the module in sys.modules before calling exec_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__.py and 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_cache after adding new sys.path entriesPathFinder caches 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.greet and db.math_ops.
  • Implement DBFinder and DBLoader — the loader queries the database for the source and compiles/executes it.
  • Register the finder and verify that import db.greet works.
  • 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 .cfg file like app.cfg with sections [database] and [server] should be importable as import app_cfg.
  • Each section becomes a SimpleNamespace attribute on the module.
  • The finder recognises modules ending in _cfg and maps them to a .cfg file on sys.path.
  • Write a test: create a real test_app.cfg file, import it as test_app_cfg, and access test_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 AuditFinder that intercepts every find_spec call, 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_spec to 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()