Import Hooks and the Import System - Intercepting Module Loading
Reading time: ~25 minutes | Level: Advanced
Predict what happens when you run this code:
import sys
class DeprecationFinder:
DEPRECATED = {
"old_utils": "new_utils",
}
def find_module(self, fullname, path=None):
if fullname in self.DEPRECATED:
return self
def load_module(self, fullname):
replacement = self.DEPRECATED[fullname]
import warnings
warnings.warn(
f"Module '{fullname}' is deprecated. Use '{replacement}' instead.",
DeprecationWarning,
stacklevel=2,
)
__import__(replacement)
sys.modules[fullname] = sys.modules[replacement]
return sys.modules[fullname]
sys.meta_path.insert(0, DeprecationFinder())
import old_utils # What happens here?
Click to reveal the answer
When import old_utils executes, Python consults sys.meta_path finders. DeprecationFinder.find_module returns self for "old_utils", claiming it can load the module. Python then calls load_module("old_utils"), which issues a DeprecationWarning, imports new_utils as the actual module, and installs it in sys.modules under both names. From this point, old_utils and new_utils are the same module object. This is a real pattern used for backwards-compatible module renames.
(Note: this uses the legacy finder/loader API. The modern approach uses MetaPathFinder.find_spec, covered below.)
What You Will Learn
- The complete import flow:
sys.modulescache,sys.meta_pathfinders,sys.pathentries, and the fallback chain - The
MetaPathFinderandLoaderprotocols, and howModuleSpecties them together - How to write a custom finder that intercepts imports and loads from non-standard sources
- How to implement lazy imports that defer module loading until first attribute access
- How pytest rewrites
assertstatements using an import hook that transforms the AST - Why importing can execute arbitrary code, how
__init__.pyworks, and how to fix circular imports - How to use
importlib.metadatafor reading package metadata and discovering plugins via entry points
Prerequisites
- Understanding of Python modules and packages (
__init__.py, relative imports) - Familiarity with
sys.pathand how Python locates modules - Basic knowledge of Abstract Syntax Trees (helpful for Part 5, but not required)
- Comfort with metaclasses and
__init_subclass__from previous lessons
Part 1 - How Python Imports Work
When you write import foo, Python executes a specific search protocol. Understanding this protocol is the foundation for everything else in this lesson.
The Import Flow
Step 1: The sys.modules Cache
Every successfully imported module is cached in sys.modules, a dict mapping module names to module objects:
import sys
import json
print("json" in sys.modules) # True
print(sys.modules["json"]) # <module 'json' from '...'>
print(json is sys.modules["json"]) # True - same object
# You can even manipulate the cache:
import json as j1
sys.modules["json"] = type(sys)("json") # replace with empty module
import json as j2
print(j1 is j2) # False - j2 is the replacement
Modifying sys.modules is powerful but dangerous. Removing a module from the cache forces a re-import on next import statement, but objects that already reference the old module will still use it. This is a common source of subtle bugs in testing and hot-reloading systems.
Step 2: sys.meta_path Finders
If the module is not cached, Python iterates through sys.meta_path - a list of finder objects:
import sys
for finder in sys.meta_path:
print(type(finder).__name__)
# BuiltinImporter - handles built-in modules (sys, builtins)
# FrozenImporter - handles frozen modules (used in executables)
# PathFinder - handles filesystem-based imports via sys.path
Each finder has a find_spec(fullname, path, target) method. The first finder to return a ModuleSpec (instead of None) wins.
Step 3: sys.path and the PathFinder
The PathFinder (the last entry in sys.meta_path) searches sys.path - a list of directories and zip files:
import sys
for p in sys.path[:5]:
print(p)
# '' (current directory)
# /usr/lib/python3.12
# /usr/lib/python3.12/lib-dynload
# /home/user/.local/lib/python3.12/site-packages
# ...
For each entry in sys.path, PathFinder consults sys.path_hooks - a list of callables that can return path entry finders for specific path types (directories, zip files, etc.).
Part 2 - Finders and Loaders
The modern import system (PEP 302, refined in PEP 451) uses two key abstractions:
MetaPathFinder
from importlib.abc import MetaPathFinder
from importlib.machinery import ModuleSpec
class MyFinder(MetaPathFinder):
def find_spec(self, fullname, path, target=None):
"""
Called for every import.
Parameters:
fullname: the fully qualified module name (e.g., "foo.bar")
path: the parent package's __path__ (None for top-level)
target: the module object being reloaded (None for fresh import)
Returns:
ModuleSpec if this finder can handle the import, else None.
"""
return None # default: cannot handle this import
Loader
from importlib.abc import Loader
class MyLoader(Loader):
def create_module(self, spec):
"""Return a module object, or None for default semantics."""
return None # use default module creation
def exec_module(self, module):
"""Execute the module code in the module's namespace."""
module.greeting = "Hello from MyLoader!"
ModuleSpec
ModuleSpec ties the finder and loader together:
from importlib.machinery import ModuleSpec
spec = ModuleSpec(
name="my_module",
loader=MyLoader(),
origin="<custom>", # where the module comes from
is_package=False,
)
The complete relationship:
Part 3 - Writing a Custom Finder
Here is a practical example: a finder that loads modules from a dictionary of source code strings. This is useful for testing, embedded systems, or serving code from a database.
import sys
import types
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec
class DictSourceLoader(Loader):
"""Loads module source code from a dictionary."""
def __init__(self, source_dict):
self.source_dict = source_dict
def create_module(self, spec):
return None # use default module creation
def exec_module(self, module):
source = self.source_dict[module.__name__]
code = compile(source, f"<dict:{module.__name__}>", "exec")
exec(code, module.__dict__)
class DictSourceFinder(MetaPathFinder):
"""Finder that intercepts imports for modules stored in a dict."""
def __init__(self, source_dict):
self.source_dict = source_dict
self.loader = DictSourceLoader(source_dict)
def find_spec(self, fullname, path, target=None):
if fullname in self.source_dict:
return ModuleSpec(
name=fullname,
loader=self.loader,
origin=f"<dict:{fullname}>",
)
return None
# --- Usage ---
# Define modules as source strings (could come from a database, API, etc.)
virtual_modules = {
"greetings": """
def hello(name):
return f"Hello, {name}!"
def goodbye(name):
return f"Goodbye, {name}!"
LANGUAGE = "en"
""",
"math_extras": """
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
PI_APPROX = 355 / 113
""",
}
# Install the finder
finder = DictSourceFinder(virtual_modules)
sys.meta_path.insert(0, finder)
# Now these modules are importable even though no files exist:
import greetings
print(greetings.hello("World")) # Hello, World!
print(greetings.LANGUAGE) # en
import math_extras
print(math_extras.factorial(10)) # 3628800
print(math_extras.PI_APPROX) # 3.1415929203539825
# Clean up
sys.meta_path.remove(finder)
del sys.modules["greetings"]
del sys.modules["math_extras"]
A Namespace-Restricted Finder
A more targeted pattern: a finder that only handles a specific namespace prefix, loading from a custom backend:
class NamespaceFinder(MetaPathFinder):
"""Only intercepts imports under 'plugins.*' namespace."""
def __init__(self, plugin_registry):
self.registry = plugin_registry
def find_spec(self, fullname, path, target=None):
if not fullname.startswith("plugins."):
return None
plugin_name = fullname[len("plugins."):]
if plugin_name not in self.registry:
return None
return ModuleSpec(
name=fullname,
loader=PluginLoader(self.registry[plugin_name]),
origin=f"<plugin:{plugin_name}>",
)
class PluginLoader(Loader):
def __init__(self, plugin_config):
self.config = plugin_config
def create_module(self, spec):
return None
def exec_module(self, module):
# Load plugin from config (could be a file path, URL, etc.)
module.name = self.config["name"]
module.version = self.config["version"]
# In reality, you would load and execute the plugin code here
Always insert your custom finder at the beginning of sys.meta_path (using insert(0, ...)) if you want it to take priority over the default finders. Insert at the end if you want it to be a fallback.
Part 4 - Lazy Imports
Lazy importing defers the actual module loading until the module is first accessed. This can dramatically improve application startup time for large codebases.
Using importlib.util.LazyLoader
Python 3.4+ provides a built-in lazy loader:
import importlib.util
import sys
def lazy_import(name):
"""Import a module lazily - it is not loaded until first attribute access."""
spec = importlib.util.find_spec(name)
if spec is None:
raise ModuleNotFoundError(f"No module named {name!r}")
loader = importlib.util.LazyLoader(spec.loader)
spec.loader = loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
loader.exec_module(module)
return module
# numpy is NOT loaded yet - just a lazy placeholder:
np = lazy_import("json") # using json as example (always available)
print(type(np)) # <class 'module'> - looks like a module
# First attribute access triggers actual loading:
print(np.dumps({"key": "value"})) # {"key": "value"}
Building a Custom Lazy Module
For more control, you can build a lazy module proxy:
import importlib
import types
class LazyModule(types.ModuleType):
"""A module proxy that loads the real module on first attribute access."""
def __init__(self, name):
super().__init__(name)
self._lazy_name = name
self._lazy_loaded = False
def _load(self):
if not self._lazy_loaded:
# Import the real module
real_module = importlib.import_module(self._lazy_name)
# Copy all attributes from the real module
self.__dict__.update(real_module.__dict__)
self._lazy_loaded = True
def __getattr__(self, name):
self._load()
return self.__dict__[name]
def install_lazy(*module_names):
"""Install lazy proxies for the given module names."""
for name in module_names:
sys.modules[name] = LazyModule(name)
# Usage:
import sys
install_lazy("xml.etree.ElementTree", "csv", "sqlite3")
# None of these modules are loaded yet.
# They load on first attribute access:
import csv
reader = csv.reader(["a,b,c"]) # csv loads here
print(list(reader)) # [['a', 'b', 'c']]
When Lazy Imports Help
Lazy imports are most valuable when:
- Large dependency trees: importing a module pulls in dozens of transitive dependencies
- CLI tools: most subcommands only use a subset of imports - loading all of them upfront wastes time
- Optional dependencies: a module may or may not be installed; lazy importing defers the
ImportError
# Pattern: lazy optional dependency
def get_yaml():
"""Lazily import PyYAML - only fails if actually used."""
try:
import yaml
return yaml
except ImportError:
raise ImportError(
"PyYAML is required for YAML support. "
"Install it with: pip install pyyaml"
) from None
Python 3.12+ includes experimental support for lazy imports via importlib. The Python core team is actively working on making lazy imports a first-class feature, potentially controlled by a command-line flag or __future__ import.
Part 5 - AST Transformation on Import
This is one of the most powerful import hook patterns: modifying a module's source code (as an AST) before it is compiled. The most famous real-world example is pytest's assert rewriting.
How pytest Rewrites Assertions
When you write:
def test_addition():
result = add(2, 3)
assert result == 5
A bare assert result == 5 would give you only AssertionError with no details. Pytest installs an import hook that transforms the AST before compilation:
# What pytest's import hook effectively does:
# Original AST:
# Assert(test=Compare(left=Name('result'), ops=[Eq()], comparators=[Num(5)]))
# Rewritten AST (simplified):
# if not result == 5:
# raise AssertionError(
# f"assert {result!r} == 5\n"
# f" where {result!r} = add(2, 3)"
# )
Building a Simple AST-Transforming Import Hook
Here is a working import hook that auto-instruments all function calls with logging:
import ast
import sys
import types
import importlib.abc
import importlib.machinery
from pathlib import Path
class LoggingTransformer(ast.NodeTransformer):
"""AST transformer that adds print statements at the start of every function."""
def visit_FunctionDef(self, node):
# Create: print(f"CALL: {function_name}()")
log_stmt = ast.parse(
f'print(f"CALL: {node.name}()")'
).body[0]
# Preserve line numbers
ast.copy_location(log_stmt, node)
ast.fix_missing_locations(log_stmt)
# Insert at the beginning of the function body
node.body.insert(0, log_stmt)
# Continue visiting child nodes
self.generic_visit(node)
return node
class ASTTransformLoader(importlib.abc.SourceLoader):
"""Loader that applies AST transformations before compilation."""
def __init__(self, original_loader, transformer):
self.original_loader = original_loader
self.transformer = transformer
def get_filename(self, fullname):
return self.original_loader.get_filename(fullname)
def get_data(self, path):
return self.original_loader.get_data(path)
def source_to_code(self, data, path, *, _optimize=-1):
"""Override: parse source, transform AST, then compile."""
source = data.decode("utf-8") if isinstance(data, bytes) else data
tree = ast.parse(source, filename=path)
tree = self.transformer.visit(tree)
ast.fix_missing_locations(tree)
return compile(tree, path, "exec", dont_inherit=True,
optimize=_optimize)
class ASTTransformFinder(importlib.abc.MetaPathFinder):
"""Finder that wraps the default loader with AST transformation."""
def __init__(self, transformer, target_packages=None):
self.transformer = transformer
self.target_packages = target_packages or set()
def find_spec(self, fullname, path, target=None):
# Only transform specified packages
if not any(fullname.startswith(pkg) for pkg in self.target_packages):
return None
# Find the spec using the normal mechanism
# (temporarily remove ourselves to avoid recursion)
sys.meta_path.remove(self)
try:
spec = importlib.util.find_spec(fullname)
finally:
sys.meta_path.insert(0, self)
if spec is None or spec.loader is None:
return None
# Wrap the loader with our AST transformer
spec.loader = ASTTransformLoader(spec.loader, self.transformer)
return spec
Usage (conceptual - requires actual module files to transform):
# Install the hook for a specific package:
transformer = LoggingTransformer()
finder = ASTTransformFinder(transformer, target_packages={"myapp"})
sys.meta_path.insert(0, finder)
# Now, importing any module under "myapp" will have logging injected:
# import myapp.handlers # every function gets a print("CALL: ...") at start
AST transformation import hooks are powerful but create significant debugging challenges. The source code you see in your editor is not what actually runs. Use them sparingly and document them heavily. Pytest limits its assertion rewriting to test files only - it does not rewrite application code.
Part 6 - Import-Time Side Effects
Why Importing Executes Code
When Python imports a module, it executes the entire module body. This means importing can trigger arbitrary side effects:
# mymodule.py
print("Module is being imported!") # runs on import
DB_CONNECTION = connect_to_database() # runs on import
class MyClass:
pass # class body executes, __init_subclass__ fires
register_signal_handlers() # runs on import
This is by design - module-level code initializes the module. But it creates problems:
__init__.py and Package Imports
When you import a package, Python executes its __init__.py:
# mypackage/__init__.py
from .core import Engine # imports core module
from .utils import helpers # imports utils module
print("Package initialized") # side effect
Heavy __init__.py files that import everything are a major cause of slow startup:
# BAD: __init__.py that imports everything
from .models import User, Product, Order, ...
from .views import UserView, ProductView, ...
from .serializers import ...
# GOOD: __init__.py that imports minimally
# Let users import what they need explicitly:
# from mypackage.models import User
Circular Imports
Circular imports are the most common import-related bug. They happen when two modules depend on each other:
# a.py
from b import b_function
def a_function():
return "a"
# b.py
from a import a_function # ImportError or AttributeError!
def b_function():
return "b"
When a.py starts importing, it runs from b import b_function. This triggers importing b.py, which tries from a import a_function. But a.py has not finished executing - a_function does not exist yet.
Strategies to fix circular imports:
1. Move the import inside the function:
# b.py
def b_function():
from a import a_function # deferred - only imported when called
return a_function()
2. Import the module, not the name:
# b.py
import a # works - the module object exists even if incomplete
def b_function():
return a.a_function() # accessed at call time, not import time
3. Restructure to eliminate the cycle:
The cleanest solution is almost always restructuring. If a and b both need something from each other, that shared functionality belongs in a third module common that both can import without cycles.
Part 7 - importlib.metadata - Package Metadata and Plugin Discovery
importlib.metadata (Python 3.8+) provides access to installed package metadata and entry points - the standard mechanism for plugin discovery.
Reading Package Metadata
from importlib.metadata import metadata, version, packages_distributions
# Get package version:
print(version("pip")) # e.g., 24.0
print(version("setuptools")) # e.g., 69.5.1
# Get full metadata:
pip_meta = metadata("pip")
print(pip_meta["Summary"]) # The PyPA recommended tool for installing Python packages.
print(pip_meta["Author"])
print(pip_meta["License"])
Entry Points for Plugin Discovery
Entry points are the standard mechanism for Python plugin systems. A package declares entry points in its pyproject.toml or setup.cfg, and other code discovers them at runtime:
# In a plugin package's pyproject.toml:
[project.entry-points."myapp.plugins"]
json_handler = "myapp_json:JsonPlugin"
xml_handler = "myapp_xml:XmlPlugin"
from importlib.metadata import entry_points
# Discover all plugins registered under "myapp.plugins":
plugins = entry_points(group="myapp.plugins")
for ep in plugins:
print(f"Plugin: {ep.name}")
print(f" Value: {ep.value}")
print(f" Module: {ep.module}")
# Load the plugin class:
plugin_cls = ep.load()
plugin = plugin_cls()
plugin.handle(data)
Building a Plugin System with Entry Points
A complete pattern for entry-point-based plugin discovery:
from importlib.metadata import entry_points
from abc import ABC, abstractmethod
class PluginInterface(ABC):
"""All plugins must implement this interface."""
@abstractmethod
def name(self) -> str:
...
@abstractmethod
def execute(self, data: dict) -> dict:
...
class PluginManager:
"""Discovers and manages plugins via entry points."""
ENTRY_POINT_GROUP = "myapp.processors"
def __init__(self):
self._plugins: dict[str, PluginInterface] = {}
self._discover()
def _discover(self):
"""Load all installed plugins."""
eps = entry_points(group=self.ENTRY_POINT_GROUP)
for ep in eps:
try:
plugin_cls = ep.load()
if not issubclass(plugin_cls, PluginInterface):
print(f"Warning: {ep.name} does not implement PluginInterface")
continue
plugin = plugin_cls()
self._plugins[plugin.name()] = plugin
print(f"Loaded plugin: {plugin.name()}")
except Exception as e:
print(f"Failed to load plugin {ep.name}: {e}")
def get(self, name: str) -> PluginInterface:
if name not in self._plugins:
raise KeyError(
f"Plugin {name!r} not found. "
f"Available: {list(self._plugins.keys())}"
)
return self._plugins[name]
def all_plugins(self) -> list[PluginInterface]:
return list(self._plugins.values())
def execute_all(self, data: dict) -> list[dict]:
"""Run all plugins on the data and return results."""
results = []
for name, plugin in self._plugins.items():
try:
result = plugin.execute(data)
results.append({"plugin": name, "result": result})
except Exception as e:
results.append({"plugin": name, "error": str(e)})
return results
This is the same pattern used by:
- pytest for discovering plugins (
pytest11entry point group) - Flask for CLI commands
- setuptools for
console_scripts - pip for build backends
Entry points are the Pythonic way to build plugin systems for distributed packages. For plugins within a single codebase, __init_subclass__ (covered in a previous lesson) is simpler and does not require package metadata.
Key Takeaways
- Python's import flow: check
sys.modulescache, then iteratesys.meta_pathfinders, then searchsys.path - Custom finders implement
find_spec()and return aModuleSpecwith aLoader - Loaders implement
create_module()andexec_module()to create and populate module objects - Lazy imports defer module loading until first attribute access - critical for startup performance
- AST transformation hooks (like pytest's assert rewriting) modify source code before compilation
- Importing a module executes its entire body - be aware of side effects, especially in
__init__.py - Circular imports are caused by mutual dependencies at import time - fix by deferring imports, importing modules instead of names, or restructuring
importlib.metadata.entry_points()is the standard mechanism for plugin discovery across installed packages
Graded Practice Challenges
Level 1 - Predict the Output
Question 1:
import sys
# Assume no module named "phantom" exists anywhere
print("phantom" in sys.modules)
try:
import phantom
except ModuleNotFoundError:
pass
print("phantom" in sys.modules)
Answer
False
False
A failed import does not add the module to sys.modules. The cache only stores successfully imported modules. This is important - if the first import fails, a subsequent import phantom will retry the search (it does not cache the failure).
Question 2:
import sys
import types
# Manually inject a module into sys.modules
fake = types.ModuleType("fake_module")
fake.value = 42
fake.greet = lambda name: f"Hi, {name}"
sys.modules["fake_module"] = fake
import fake_module
print(fake_module.value)
print(fake_module.greet("World"))
print(type(fake_module))
Answer
42
Hi, World
<class 'module'>
sys.modules is checked before any finders are consulted. By inserting a module object directly into sys.modules, import fake_module finds it immediately and returns it. No file system search occurs. This is how mock modules, virtual modules, and module aliases work.
Question 3:
import sys
original_meta_path_len = len(sys.meta_path)
class NullFinder:
def find_spec(self, fullname, path, target=None):
if fullname == "blocked_module":
raise ModuleNotFoundError(f"{fullname} is blocked!")
return None
sys.meta_path.insert(0, NullFinder())
try:
import json # already in sys.modules from earlier
print("json imported successfully")
except ModuleNotFoundError:
print("json blocked")
try:
import blocked_module
print("blocked_module imported successfully")
except ModuleNotFoundError as e:
print(f"Error: {e}")
sys.meta_path.pop(0)
Answer
json imported successfully
Error: blocked_module is blocked!
json was already cached in sys.modules from a previous import, so the finders are never consulted - it succeeds regardless of the NullFinder. blocked_module is not cached, so Python checks sys.meta_path. NullFinder.find_spec raises ModuleNotFoundError, which propagates up.
Level 2 - Debug Challenge
This import hook is supposed to create "virtual config modules" that expose environment variables as module attributes, but it has a bug:
import sys
import os
import types
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec
class EnvLoader(Loader):
def __init__(self, prefix):
self.prefix = prefix
def create_module(self, spec):
return types.ModuleType(spec.name)
def exec_module(self, module):
prefix = self.prefix.upper() + "_"
for key, value in os.environ.items():
if key.startswith(prefix):
attr_name = key[len(prefix):].lower()
setattr(module, attr_name, value)
class EnvFinder(MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname.startswith("config."):
section = fullname.split(".")[-1]
return ModuleSpec(
name=fullname,
loader=EnvLoader(section),
)
sys.meta_path.insert(0, EnvFinder())
# Set some env vars for testing:
os.environ["DATABASE_HOST"] = "localhost"
os.environ["DATABASE_PORT"] = "5432"
# This should work:
import config.database
print(config.database.host) # Expected: localhost
print(config.database.port) # Expected: 5432
Hint
What happens when you import config.database? Python first needs to import the config package. Does config exist? What does the finder return for fullname="config"?
Solution
The bug: when Python processes import config.database, it first imports config as a package. The finder's check fullname.startswith("config.") does NOT match "config" (no dot), so find_spec returns None for the parent. Python then raises ModuleNotFoundError: No module named 'config'.
Fix: also handle the parent package:
class EnvFinder(MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname == "config":
# Create the parent package
spec = ModuleSpec(
name="config",
loader=PackageLoader(),
is_package=True,
)
spec.submodule_search_locations = []
return spec
if fullname.startswith("config."):
section = fullname.split(".")[-1]
return ModuleSpec(
name=fullname,
loader=EnvLoader(section),
)
class PackageLoader(Loader):
def create_module(self, spec):
return types.ModuleType(spec.name)
def exec_module(self, module):
module.__path__ = [] # mark as package
Level 3 - Design Challenge
Build a "module versioning" import hook system with these requirements:
- A
VersionedFinderthat intercepts imports likeimport mylib_v2and maps them to actual modules with version selection - A registry where you register module versions:
register_version("mylib", 2, "path/to/mylib_v2.py") import mylib_v2loads the registered v2 source,import mylib_v1loads v1- The loaded module should have a
__version__attribute set automatically - If the requested version is not registered, raise
ModuleNotFoundErrorwith a helpful message listing available versions - A
set_default_version("mylib", 2)that makesimport mylibload v2
Your implementation should handle the finder, loader, and version registry. Test with at least two versions of a mock module.
Solution Sketch
import sys
import types
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec
import re
class VersionRegistry:
def __init__(self):
self._versions = {} # {module_name: {version: source}}
self._defaults = {} # {module_name: version}
def register(self, name, version, source):
self._versions.setdefault(name, {})[version] = source
def set_default(self, name, version):
if name not in self._versions or version not in self._versions[name]:
raise ValueError(f"No version {version} registered for {name}")
self._defaults[name] = version
def get(self, name, version):
versions = self._versions.get(name, {})
if version not in versions:
available = list(versions.keys())
raise ModuleNotFoundError(
f"Version {version} of '{name}' not found. "
f"Available: {available}"
)
return versions[version]
def get_default(self, name):
if name in self._defaults:
return self._defaults[name], self._versions[name][self._defaults[name]]
return None, None
def has_module(self, name):
return name in self._versions
registry = VersionRegistry()
class VersionedLoader(Loader):
def __init__(self, source, version):
self.source = source
self.version = version
def create_module(self, spec):
return None
def exec_module(self, module):
module.__version__ = self.version
code = compile(self.source, f"<{module.__name__}>", "exec")
exec(code, module.__dict__)
class VersionedFinder(MetaPathFinder):
VERSION_PATTERN = re.compile(r"^(.+)_v(\d+)$")
def find_spec(self, fullname, path, target=None):
# Check for versioned import: mylib_v2
match = self.VERSION_PATTERN.match(fullname)
if match:
name, ver = match.group(1), int(match.group(2))
if registry.has_module(name):
source = registry.get(name, ver)
return ModuleSpec(
name=fullname,
loader=VersionedLoader(source, ver),
)
# Check for default version: mylib
if registry.has_module(fullname):
ver, source = registry.get_default(fullname)
if ver is not None:
return ModuleSpec(
name=fullname,
loader=VersionedLoader(source, ver),
)
return None
# Install
sys.meta_path.insert(0, VersionedFinder())
# Register versions
registry.register("mylib", 1, "def greet(): return 'Hello v1'")
registry.register("mylib", 2, "def greet(): return 'Hello v2'\ndef new_feature(): return 'v2 only'")
registry.set_default("mylib", 2)
# Test
import mylib_v1
print(mylib_v1.greet()) # Hello v1
print(mylib_v1.__version__) # 1
import mylib_v2
print(mylib_v2.greet()) # Hello v2
print(mylib_v2.new_feature()) # v2 only
print(mylib_v2.__version__) # 2
import mylib
print(mylib.greet()) # Hello v2 (default)
print(mylib.__version__) # 2
What's Next
You have now completed the core metaprogramming lessons - metaclasses, descriptors, __init_subclass__, __set_name__, dynamic class creation, and import hooks. In the module project, you will combine these techniques to build a custom ORM core from scratch, applying everything from descriptors to metaclasses to plugin discovery.
