Python The Python Import System —: Practice Problems & Exercises
Practice: The Python Import System — importlib, Finders, Loaders, and Import Hooks
← Back to lessonEasy
Predict the output. This tests whether you understand the sys.modules caching mechanism.
import sys # Import json — module is now in sys.modules import json first_import = sys.modules["json"] # Import json again — same object returned import json as json2 print(json is json2) # Both variables point to the object in sys.modules print(sys.modules["json"] is json) # Remove json from sys.modules — forces re-import del sys.modules["json"] import json as json3 # json3 is a fresh module object — NOT the same as json print(json is json3)
Solution
True
True
False
Why json is json3 is False:
After del sys.modules["json"], the "json" key no longer exists in the cache. The next import json as json3 triggers a full module load: Python finds json.py, executes it, and stores a fresh module object in sys.modules["json"]. This new object has a different identity (id) than the original json object still referenced by the json variable.
The original json object is still alive (the json variable holds a reference to it), but it is no longer the object that sys.modules["json"] points to.
Practical consequence: If module A holds a reference to json and module B deletes and reloads json, module A still uses the old module object. This is why manually manipulating sys.modules is dangerous — it can create inconsistent state where different parts of the program hold references to different versions of the same module.
When this pattern is intentional: Test isolation frameworks sometimes remove and reinstall modules between tests to ensure clean state. They must be careful to also reset any variables in other modules that hold references to the old module object.
Expected Output
True\nTrue\nFalseHints
Hint 1: The first time a module is imported, Python executes its code and stores the result in sys.modules.
Hint 2: Every subsequent import of the same module returns the cached object without re-executing the file.
Hint 3: Removing a name from sys.modules forces the next import to reload the module from disk.
Predict which assertions pass. Dynamic imports using importlib.import_module() are a common pattern in plugin systems.
import importlib
import sys
# Dynamic import by string name
json_module = importlib.import_module("json")
# Should be the same as: import json
import json
print(json_module is json)
# After dynamic import, module is in sys.modules
print("json" in sys.modules)
# importlib.import_module returns the module object
import types
print(isinstance(json_module, types.ModuleType))Solution
True
True
True
Why dynamic imports are useful:
importlib.import_module("json") accepts a string, which means the module name can be determined at runtime — from a config file, a plugin registry, or a command-line argument.
Real-world patterns:
Plugin systems:
plugins = ["my_app.plugins.auth", "my_app.plugins.logging"]
for plugin_path in plugins:
module = importlib.import_module(plugin_path)
module.register()
Django's app loading:
# When Django processes INSTALLED_APPS:
for app_path in settings.INSTALLED_APPS:
module = importlib.import_module(app_path)
# Find AppConfig subclass and register it
Optional dependencies:
try:
ujson = importlib.import_module("ujson")
except ImportError:
import json as ujson # fall back to stdlib
Relative imports with importlib:
importlib.import_module(".models", package="my_app") is equivalent to from my_app import models. The package argument provides the anchor for relative resolution.
Expected Output
True\nTrue\nTrueHints
Hint 1: importlib.import_module("name") is equivalent to import name, but takes the module name as a string.
Hint 2: It returns the module object, just like import does.
Hint 3: The module is also stored in sys.modules after the call.
Predict which assertions pass. Understanding __all__ is essential for writing libraries with clean public APIs.
import sys
import types
# Simulate a module with __all__
fake_module = types.ModuleType("fake_mod")
fake_module.__all__ = ["public_func", "PublicClass"]
fake_module.public_func = lambda: "public"
fake_module.PublicClass = type("PublicClass", (), {})
fake_module._private = "hidden"
fake_module.also_not_listed = "also hidden from star import"
sys.modules["fake_mod"] = fake_module
# Simulate what "from fake_mod import *" would import
# (We do this manually to avoid actual star-import syntax restrictions)
star_names = fake_module.__all__
print("public_func" in star_names)
print("PublicClass" in star_names)
print("also_not_listed" in star_names)Solution
True
True
False
The rules of __all__:
-
If
__all__is defined:from module import *imports exactly the names in__all__, nothing more. Names not in__all__are inaccessible via star import, even if they are public (no underscore). -
If
__all__is NOT defined:from module import *imports all names that do not start with a single underscore (_). Dunder names like__version__are imported. Names starting with_or__are not. -
__all__does NOT prevent direct imports.from fake_mod import also_not_listedstill works even ifalso_not_listedis not in__all__.
Best practice: Always define __all__ in library code. It serves as the explicit public API contract. It prevents users from accidentally importing internal implementation details. Many linters enforce this.
Common mistake: Forgetting to add a new public name to __all__ when adding it to a module. Automated checks (pylint with missing-module-docstring, or flake8-bugbear) can catch this.
Expected Output
True\nTrue\nFalseHints
Hint 1: When __all__ is defined, from module import * imports only the names in __all__.
Hint 2: Without __all__, from module import * imports all names that do not start with an underscore.
Hint 3: __all__ does not prevent direct imports — it only affects star imports.
Predict which assertions pass. importlib.reload() updates a module without creating a new object.
import importlib
import sys
import types
# Create a fake module with a mutable counter
mod = types.ModuleType("counter_mod")
mod.count = 0
mod.increment = lambda: None # placeholder
sys.modules["counter_mod"] = mod
original_id = id(mod)
# Simulate changing the module's count (as if reload() re-ran the module code)
mod.count = 42
# After reload, the module object is the same object (same id)
# (We cannot actually reload a types.ModuleType, so we verify the concept)
import counter_mod
reloaded_mod = sys.modules["counter_mod"]
print(id(reloaded_mod) == original_id)
# The mutation survives because it is the same object
print(reloaded_mod.count == 42)Solution
True
True
What importlib.reload() actually does:
- Finds the module's spec (its source file location).
- Re-executes the module's source code in the existing module object's namespace (
module.__dict__). - Returns the same module object (same
id).
Because the module object is updated in-place, all existing references to the module (e.g., import json; json.some_func) automatically see the new definitions after reload.
A critical subtlety:
# Before reload:
from mymodule import MyClass # local variable points to old MyClass object
importlib.reload(mymodule) # mymodule.MyClass is now a new class object
# After reload:
# mymodule.MyClass → new class (updated)
# local MyClass variable → still the OLD class object (NOT updated)
from module import name creates a local binding at import time. Reloading the module updates module.name but does NOT update local bindings. This is the most common source of confusion with importlib.reload.
Safe reload pattern:
import importlib
import mymodule
importlib.reload(mymodule)
# Re-run all "from mymodule import X" statements to get fresh bindings
Expected Output
True\nTrueHints
Hint 1: importlib.reload(module) re-executes the module file and updates the existing module object in-place.
Hint 2: The module object identity does NOT change after reload — it is the same object, but its attributes are updated.
Hint 3: reload() is useful for development hot-reloading and for modules that read configuration from disk.
Medium
Trace through the circular import simulation and predict the output. Then explain why the standard fix is to restructure the import graph, not to delay imports.
import sys
import types
# Simulate the circular import scenario described above
before, after = simulate_circular_import()
print(f"A available during module_b import: {before}")
print(f"A available after full init: {after}")
print(f"Circular import problem: {not before and after}")Solution
A available during module_b import: False
A available after full init: True
Circular import problem: True
The exact sequence in a real circular import:
# module_a.py
from module_b import B # triggers module_b to load
class A:
pass
# module_b.py
from module_a import A # module_a is in sys.modules but A not defined yet!
class B:
pass
When Python imports module_a:
- Creates
module_aobject, adds it tosys.modules["module_a"]. - Executes
module_a.pyline by line. - Hits
from module_b import B— starts importingmodule_b. - Creates
module_bobject, adds it tosys.modules["module_b"]. - Executes
module_b.pyline by line. - Hits
from module_a import A— findsmodule_ainsys.modules. - Tries to look up
Ainmodule_a—Adoes not exist yet (step 3 hasn't finished). ImportError: cannot import name 'A' from partially initialized module 'module_a'.
The three fixes, ranked by quality:
-
Restructure (best): Extract the shared definition (e.g.,
A) into a third module that bothmodule_aandmodule_bimport. No cycle. -
Late import (good): Move
from module_a import Ainside the function that needs it, not at module top-level. The import runs after all modules are fully initialized. -
Import module, not name (acceptable):
import module_ainstead offrom module_a import A. Then usemodule_a.Aat call time, not at import time. Works becausemodule_ais insys.modulesby the time the function runs.
import sys
import types
# Simulate what happens with a circular import between module_a and module_b
# module_a does: from module_b import B
# module_b does: from module_a import A
# This creates a problem because when module_b tries to import A from module_a,
# module_a is only half-initialized.
def simulate_circular_import():
"""Simulate circular import resolution.
Return a description of when the import fails.
"""
# Step 1: Start importing module_a
# module_a is added to sys.modules BEFORE its code runs
mod_a = types.ModuleType("module_a")
sys.modules["module_a"] = mod_a
# Step 2: module_a's code starts running
# It tries to import B from module_b
# This triggers module_b to start importing
mod_b = types.ModuleType("module_b")
sys.modules["module_b"] = mod_b
# Step 3: module_b's code starts running
# It tries to import A from module_a
# module_a IS in sys.modules, but it's not fully initialized yet
# So module_a.A does not exist yet
has_A_yet = hasattr(mod_a, "A")
# Step 4: module_a finishes initializing, defines A
mod_a.A = "class A"
mod_b.B = "class B"
# Step 5: after full init, everything is accessible
has_A_after = hasattr(mod_a, "A")
return has_A_yet, has_A_after
before, after = simulate_circular_import()
print(f"A available during module_b import: {before}")
print(f"A available after full init: {after}")
print(f"Circular import problem: {not before and after}")Expected Output
A available during module_b import: False\nA available after full init: True\nCircular import problem: TrueHints
Hint 1: Python adds a module to sys.modules BEFORE executing its code. This prevents infinite recursion.
Hint 2: But it also means the module is visible but empty (half-initialized) when the second module tries to import from it.
Hint 3: The solution is usually: (1) move imports inside functions, (2) restructure to eliminate the cycle, or (3) use importlib.import_module() lazily.
Load a Python file as a module given only its filesystem path — bypassing the normal import system. This pattern is used by test frameworks, plugin loaders, and code analysis tools.
import importlib.util
import sys
def load_module_from_file(filepath, module_name):
"""Load filepath as a Python module with the given name."""
pass
Solution
import importlib.util
import sys
def load_module_from_file(filepath, module_name):
"""Load a Python file as a module given its absolute path."""
spec = importlib.util.spec_from_file_location(module_name, filepath)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module # add before exec to handle circular imports
spec.loader.exec_module(module)
return module
The three-step protocol:
-
spec_from_file_location(name, path)— creates aModuleSpecdescribing where the module lives and which loader to use. The spec records the module's name, origin (file path), submodule search locations, and a reference to the appropriateSourceFileLoader. -
module_from_spec(spec)— creates an emptytypes.ModuleTypeobject and populates standard dunder attributes:__name__,__loader__,__spec__,__file__,__package__. -
spec.loader.exec_module(module)— callscompile()on the source file and thenexec()in the module's__dict__. This is the step that actually runs the module code.
Why add to sys.modules before exec_module:
If the loaded module imports itself (circular) or is imported by something else during loading, having it in sys.modules prevents infinite recursion. If you add it after exec_module, circular imports within the loaded file will fail.
Real-world uses:
pytest— loads test files by path, not by module name.- IDE plugins — load user scripts for linting/formatting.
sphinx— loadsconf.pyby path.- Build systems — load
setup.py,pyproject.tomlextension scripts.
import importlib.util
import sys
import tempfile
import os
# Write a temporary Python file
module_code = """
VERSION = "1.0.0"
def greet(name):
return f"Hello, {name}!"
class Config:
debug = False
"""
def load_module_from_file(filepath, module_name):
"""Load a Python file as a module given its absolute path.
Return the loaded module object.
"""
pass
# Write the file and load it
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(module_code)
tmppath = f.name
try:
mod = load_module_from_file(tmppath, "dynamic_module")
print(f"VERSION: {mod.VERSION}")
print(f"greet works: {mod.greet('World') == 'Hello, World!'}")
print(f"Config.debug: {mod.Config.debug}")
print(f"Module in sys.modules: {'dynamic_module' in sys.modules}")
finally:
os.unlink(tmppath)
sys.modules.pop("dynamic_module", None)Expected Output
VERSION: 1.0.0\ngreet works: True\nConfig.debug: False\nModule in sys.modules: TrueHints
Hint 1: importlib.util.spec_from_file_location(name, filepath) creates a ModuleSpec from a file path.
Hint 2: importlib.util.module_from_spec(spec) creates an empty module object from the spec.
Hint 3: spec.loader.exec_module(module) executes the module file, populating the module object.
Hint 4: Add the module to sys.modules BEFORE exec_module to handle potential circular imports.
Build a function that temporarily adds a directory to sys.path for a single import, then restores the original path. This is a safer alternative to permanently mutating sys.path.
import sys
import importlib
def load_from_custom_path(directory, module_name):
"""Add directory to sys.path, import module_name, restore sys.path."""
pass
Solution
import sys
import importlib
def load_from_custom_path(directory, module_name):
"""Temporarily add directory to sys.path and import module_name."""
original_path = sys.path[:] # shallow copy
try:
sys.path.insert(0, directory)
return importlib.import_module(module_name)
finally:
sys.path[:] = original_path # restore in-place
Why sys.path[:] for both save and restore:
original_path = sys.path[:]creates a copy of the list contents. If we just didoriginal_path = sys.path, we would have two names pointing to the same list — mutations would affect both.sys.path[:] = original_pathrestores the contents of the existing list object in-place. This is important because other parts of the code may hold references tosys.path— they should see the restoration.
Why insert at position 0:
sys.path is searched left-to-right. Inserting at index 0 ensures our directory takes priority over system-wide packages. If we appended instead, a system package with the same name could shadow our module.
The contextmanager pattern (recommended for production):
import sys
import contextlib
@contextlib.contextmanager
def temp_sys_path(directory):
original = sys.path[:]
sys.path.insert(0, directory)
try:
yield
finally:
sys.path[:] = original
with temp_sys_path("/opt/plugins"):
import my_plugin
Using a context manager makes the intent explicit and composes well with other context managers.
import sys
import os
import tempfile
def load_from_custom_path(directory, module_name):
"""Temporarily add directory to sys.path, import module_name,
then restore sys.path to its original state.
Return the loaded module.
Ensure sys.path is always restored even if the import fails.
"""
pass
# Create a temporary directory with a module
with tempfile.TemporaryDirectory() as tmpdir:
# Write a simple module
module_path = os.path.join(tmpdir, "myconfig.py")
with open(module_path, "w") as f:
f.write("SETTING = 'production'\nDEBUG = False\n")
# Remove from sys.modules if it was previously imported
sys.modules.pop("myconfig", None)
mod = load_from_custom_path(tmpdir, "myconfig")
print(f"SETTING: {mod.SETTING}")
print(f"DEBUG: {mod.DEBUG}")
print(f"sys.path restored: {tmpdir not in sys.path}")Expected Output
SETTING: production\nDEBUG: False\nsys.path restored: TrueHints
Hint 1: Save the original sys.path with a copy: original = sys.path[:]
Hint 2: Insert directory at the front of sys.path: sys.path.insert(0, directory)
Hint 3: Use try/finally to guarantee sys.path is restored even on ImportError.
Hint 4: Use importlib.import_module(module_name) to perform the import.
The lazy module implementation above is already written — trace through it and predict the output. Then explain how this pattern is used by major libraries.
# Run the lazy module demo above and trace through the output
Solution
loads works: True
dumps works: True
Cached: True
AttributeError raised: True
How module-level __getattr__ works (PEP 562, Python 3.7+):
When Python evaluates module.attribute, it:
- Looks in
module.__dict__for the attribute. - If not found, calls
module.__getattr__(attribute)if it exists. - If
__getattr__raisesAttributeError, Python propagates it.
This is the same protocol used for class instances, but applied to module objects.
Real-world uses:
scipy:
# scipy/__init__.py (simplified)
_lazy_imports = {
"fft": "scipy.fft",
"linalg": "scipy.linalg",
"stats": "scipy.stats",
}
def __getattr__(name):
if name in _lazy_imports:
return importlib.import_module(_lazy_imports[name])
raise AttributeError(f"module 'scipy' has no attribute {name!r}")
import scipy takes 0.1 seconds. import scipy.fft takes 0.5 seconds. With lazy loading, import scipy is fast, and scipy.fft only loads when first accessed.
typing module (Python 3.12+):
The typing module uses __getattr__ to lazily provide deprecated aliases, emitting deprecation warnings on first access.
The caching inside __getattr__: Without _cache, every access to lazy_json.loads would call __getattr__ and re-import. By storing the result in _cache (and ideally in mod.__dict__), subsequent accesses bypass __getattr__ entirely for maximum performance.
Better pattern: Instead of _cache, store the value in the module's __dict__ directly:
def __getattr__(name):
value = do_the_import(name)
mod.__dict__[name] = value # next access finds it in __dict__, skips __getattr__
return value
import sys
import types
# Python 3.7+ supports module-level __getattr__.
# When an attribute is not found on a module, Python calls module.__getattr__(name).
# This enables lazy importing: defer expensive imports until first use.
def make_lazy_module(module_name, lazy_imports):
"""Create a module object whose attributes are lazily imported on first access.
lazy_imports is a dict: {attr_name: (module_path, attr_in_module)}
e.g., {"loads": ("json", "loads")} means mymod.loads -> json.loads
"""
mod = types.ModuleType(module_name)
_cache = {}
def __getattr__(name):
if name in _cache:
return _cache[name]
if name in lazy_imports:
source_module, source_attr = lazy_imports[name]
import importlib
m = importlib.import_module(source_module)
value = getattr(m, source_attr)
_cache[name] = value
return value
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
mod.__getattr__ = __getattr__
sys.modules[module_name] = mod
return mod
# Build a lazy module that defers json.loads and json.dumps
lazy_json = make_lazy_module(
"lazy_json",
{
"loads": ("json", "loads"),
"dumps": ("json", "dumps"),
}
)
# Access triggers lazy loading
result = lazy_json.loads('[1, 2, 3]')
print(f"loads works: {result == [1, 2, 3]}")
encoded = lazy_json.dumps({"key": "value"})
print(f"dumps works: {'key' in encoded}")
# Second access uses cache
loads_again = lazy_json.loads
print(f"Cached: {loads_again is lazy_json.loads}")
# Non-existent attribute raises AttributeError
try:
_ = lazy_json.nonexistent
print("No error raised: False")
except AttributeError:
print("AttributeError raised: True")Expected Output
loads works: True\ndumps works: True\nCached: True\nAttributeError raised: TrueHints
Hint 1: module.__getattr__(name) is called by Python when normal attribute lookup fails on a module object.
Hint 2: You can assign a __getattr__ function to any module object to intercept missing attribute access.
Hint 3: The lazy import triggers importlib.import_module on first access; subsequent accesses use _cache.
Hard
Implement a custom import hook that serves Python modules from an in-memory dictionary. When an import targets a name in IN_MEMORY_MODULES, your finder intercepts the import and your loader compiles and executes the source code — no files involved.
import sys
import importlib.abc
import importlib.machinery
import importlib.util
IN_MEMORY_MODULES = {}
class InMemoryFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
pass
class InMemoryLoader(importlib.abc.Loader):
def create_module(self, spec): return None
def exec_module(self, module): pass
Solution
import sys
import importlib.abc
import importlib.util
IN_MEMORY_MODULES = {}
class InMemoryFinder(importlib.abc.MetaPathFinder):
"""Finds modules registered in IN_MEMORY_MODULES."""
def find_spec(self, fullname, path, target=None):
if fullname in IN_MEMORY_MODULES:
loader = InMemoryLoader(fullname)
return importlib.util.spec_from_loader(fullname, loader)
return None # tell Python to try the next finder
class InMemoryLoader(importlib.abc.Loader):
"""Loads a module from IN_MEMORY_MODULES source string."""
def __init__(self, fullname):
self.fullname = fullname
def create_module(self, spec):
return None # use default module creation (types.ModuleType)
def exec_module(self, module):
source = IN_MEMORY_MODULES[self.fullname]
code = compile(source, f"<in-memory:{self.fullname}>", "exec")
exec(code, module.__dict__)
The meta path finder protocol:
When Python processes import name:
- Checks
sys.modules— if found, return it. - Iterates
sys.meta_pathfinders in order. - Calls
finder.find_spec(name, path, target)on each finder. - If a finder returns a
ModuleSpec(not None), use that finder's loader. - If all finders return None, try
sys.path_hooks(file-based finders). - If still not found:
ModuleNotFoundError.
Why insert at position 0:
By inserting at sys.meta_path[0], our InMemoryFinder runs before the built-in finders. If we appended, a real file named virtual_math.py on sys.path would be found first.
Real-world import hooks:
zipimport— Python's built-in hook for importing from.zipfiles (also.eggfiles).pkgutil.ImpImporter— legacy hook, superseded byimportlib.importlib_resources— loads data files from packages.sourcemaps— some tools hook imports to transform source (add instrumentation, apply macros).- Cython — an import hook can compile
.pyxfiles on-demand.
import sys
import importlib.abc
import importlib.machinery
import importlib.util
import types
# Registry of in-memory modules: name -> source_code string
IN_MEMORY_MODULES = {}
class InMemoryFinder(importlib.abc.MetaPathFinder):
"""A meta path finder that serves modules from the IN_MEMORY_MODULES registry."""
def find_spec(self, fullname, path, target=None):
"""Return a ModuleSpec if fullname is in IN_MEMORY_MODULES, else None."""
pass
class InMemoryLoader(importlib.abc.Loader):
"""A loader that compiles and executes source code from IN_MEMORY_MODULES."""
def __init__(self, fullname):
self.fullname = fullname
def create_module(self, spec):
"""Return None to use the default module creation."""
return None
def exec_module(self, module):
"""Execute the in-memory source code in the module's namespace."""
pass
# Install the finder at the front of sys.meta_path
finder = InMemoryFinder()
sys.meta_path.insert(0, finder)
# Register an in-memory module
IN_MEMORY_MODULES["virtual_math"] = """
def add(a, b):
return a + b
def square(n):
return n * n
PI = 3.14159
"""
# Import it like any normal module
import virtual_math
print(f"add(3, 4) = {virtual_math.add(3, 4)}")
print(f"square(5) = {virtual_math.square(5)}")
print(f"PI = {virtual_math.PI}")
print(f"In sys.modules: {'virtual_math' in sys.modules}")
# Cleanup
sys.meta_path.remove(finder)
sys.modules.pop("virtual_math", None)Expected Output
add(3, 4) = 7\nsquare(5) = 25\nPI = 3.14159\nIn sys.modules: TrueHints
Hint 1: InMemoryFinder.find_spec should check if fullname is in IN_MEMORY_MODULES. If yes, return importlib.util.spec_from_loader(fullname, InMemoryLoader(fullname)). If no, return None.
Hint 2: InMemoryLoader.exec_module should get the source from IN_MEMORY_MODULES[self.fullname], compile it with compile(source, fullname, "exec"), then exec(code, module.__dict__).
Hint 3: The find_spec/exec_module split is the standard two-phase protocol: find_spec decides IF we handle this import; exec_module decides HOW.
The SingletonModule and install_singleton_module implementation is provided — trace through the execution to predict the output. Then explain the general pattern of replacing entries in sys.modules with custom objects.
# Run the SingletonModule demo above
Solution
Is singleton: True
Connection exists: True
Pool size: True
Initialized flag: True
Same object: True
The sys.modules replacement pattern:
Python does not require sys.modules values to be types.ModuleType instances. Any object can be stored there. When Python processes import name, it returns sys.modules[name] directly if the key exists — no type check is performed.
This means you can replace a real module with any object, including a class instance with custom __getattr__.
Famous examples:
antigravity (Python Easter egg):
# Cpython's Lib/antigravity.py has this at module level:
import webbrowser
webbrowser.open("https://xkcd.com/353/")
this module:
# Lib/this.py replaces sys.modules['this'] with None after printing the Zen
requests and urllib3 lazy loading:
Both libraries replaced their top-level modules with proxy objects that defer heavy sub-imports until first use, reducing import requests time from ~200ms to ~30ms.
Django's database connection (conceptually):
Django stores a thread-local connection manager at django.db.connection. Each thread gets its own connection, but all threads access it through the same module attribute. The module object does not hold the connection directly — it delegates to a thread-local storage object through __getattr__.
Warning: This pattern is powerful but fragile. Code that does type(module) is types.ModuleType will fail. Some tools (pytest, coverage, mypy) make assumptions about module types. Use with care.
import sys
import types
class SingletonModule(types.ModuleType):
"""A module subclass that enforces singleton access to a shared resource.
Accessing any attribute triggers lazy initialization of the resource.
Once initialized, the resource is cached and shared across all importers.
This is the pattern used by the logging module's root logger,
Django's database connection pool, and similar global resources.
"""
_resource = None
_initialized = False
def __getattr__(self, name):
# Lazy-initialize the resource on first attribute access
if not SingletonModule._initialized:
SingletonModule._resource = self._create_resource()
SingletonModule._initialized = True
# Return the attribute from the instance or raise AttributeError
try:
return object.__getattribute__(self, name)
except AttributeError:
return getattr(SingletonModule._resource, name)
def _create_resource(self):
"""Create the shared resource. Override in subclass."""
return {"connection": "db://localhost", "pool_size": 5}
def install_singleton_module(module_name):
"""Replace the module at module_name in sys.modules with a SingletonModule instance.
If the module doesn't exist yet, create it fresh.
Return the SingletonModule.
"""
singleton = SingletonModule(module_name)
sys.modules[module_name] = singleton
return singleton
# Install the singleton
db_module = install_singleton_module("db_connection")
# Any code that does "import db_connection" gets the singleton
import db_connection
print(f"Is singleton: {db_connection is db_module}")
print(f"Connection exists: {db_connection.connection == 'db://localhost'}")
print(f"Pool size: {db_connection.pool_size == 5}")
print(f"Initialized flag: {SingletonModule._initialized}")
# A second import also gets the same singleton
import db_connection as db2
print(f"Same object: {db_connection is db2}")
# Cleanup
sys.modules.pop("db_connection", None)Expected Output
Is singleton: True\nConnection exists: True\nPool size: True\nInitialized flag: True\nSame object: TrueHints
Hint 1: Replacing sys.modules[name] with a custom object means every import of that name returns your object.
Hint 2: SingletonModule subclasses types.ModuleType, so Python accepts it as a module.
Hint 3: __getattr__ is only called when normal attribute lookup fails — so attributes defined on the class (like _resource) are found without triggering __getattr__.
Hint 4: The lazy initialization in __getattr__ means _create_resource is called exactly once, on the first external attribute access.
Implement the EncryptedLoader.exec_module() method to complete an import hook that decrypts module source on-the-fly. This demonstrates the full finder+loader lifecycle and shows how software licensing systems and obfuscators can hook the import system.
class EncryptedLoader(importlib.abc.Loader):
def create_module(self, spec): return None
def exec_module(self, module):
"""Decrypt, compile, and execute the module source."""
pass
Solution
class EncryptedLoader(importlib.abc.Loader):
"""Decrypts source and executes it as a Python module."""
def __init__(self, fullname):
self.fullname = fullname
def create_module(self, spec):
return None # use default types.ModuleType
def exec_module(self, module):
"""Decrypt the source code and execute it in the module's namespace."""
encrypted_bytes, key = ENCRYPTED_REGISTRY[self.fullname]
source_bytes = xor_decrypt(encrypted_bytes, key)
source_str = source_bytes.decode("utf-8")
code = compile(source_str, f"<encrypted:{self.fullname}>", "exec")
exec(code, module.__dict__)
Security note on this approach:
XOR with a constant key is not real encryption — it is obfuscation. Anyone who can inspect the Python process can:
- Find the decrypted source in memory after
exec_moduleruns. - Reverse the XOR trivially.
Real license systems (e.g., older Pyc-based protection, Cython-compiled binaries) use stronger techniques — but even those can be defeated at the bytecode or native instruction level. Python's design philosophy assumes source code is readable; true source protection requires compiling to native code (Cython, Nuitka, mypyc).
Where encrypted/obfuscated module loading is legitimate:
- Shipping compiled
.pyc-only packages (no.pysource). - Cython extension modules (
.so/.pyd— machine code, much harder to reverse). - Signing/verifying modules at load time (integrity checking, not secrecy).
The broader import hook ecosystem:
The same find_spec / exec_module API is used to load modules from:
- ZIP archives (
zipimport) - Remote URLs (experimental research tools)
- Databases (module source stored in a database)
- S3 buckets (large scale deployment tools)
- AST-transforming loaders that modify source before compilation (macro systems, contract checkers)
Every one of these uses the same two-step protocol we implemented here.
import sys
import importlib.abc
import importlib.util
import base64
import os
# A simple XOR "encryption" for demonstration
def xor_encrypt(data: bytes, key: int) -> bytes:
return bytes(b ^ key for b in data)
def xor_decrypt(data: bytes, key: int) -> bytes:
return xor_encrypt(data, key) # XOR is its own inverse
# Registry: module_name -> (encrypted_source_bytes, key)
ENCRYPTED_REGISTRY = {}
def register_encrypted(module_name, source_code: str, key: int = 42):
"""Encrypt and register source_code under module_name."""
encrypted = xor_encrypt(source_code.encode(), key)
ENCRYPTED_REGISTRY[module_name] = (encrypted, key)
class EncryptedFinder(importlib.abc.MetaPathFinder):
"""Finds modules registered in ENCRYPTED_REGISTRY."""
def find_spec(self, fullname, path, target=None):
if fullname in ENCRYPTED_REGISTRY:
loader = EncryptedLoader(fullname)
return importlib.util.spec_from_loader(fullname, loader)
return None
class EncryptedLoader(importlib.abc.Loader):
"""Decrypts source and executes it as a Python module."""
def __init__(self, fullname):
self.fullname = fullname
def create_module(self, spec):
return None
def exec_module(self, module):
"""Decrypt the source code and execute it in the module's namespace."""
pass
# Install the hook
finder = EncryptedFinder()
sys.meta_path.insert(0, finder)
# Register an encrypted module
register_encrypted(
"secret_math",
source_code="""
def multiply(a, b):
return a * b
def power(base, exp):
return base ** exp
ANSWER = 42
""",
key=99
)
# Import the encrypted module normally
import secret_math
print(f"multiply(6, 7) = {secret_math.multiply(6, 7)}")
print(f"power(2, 10) = {secret_math.power(2, 10)}")
print(f"ANSWER = {secret_math.ANSWER}")
print(f"In sys.modules: {'secret_math' in sys.modules}")
# Cleanup
sys.meta_path.remove(finder)
sys.modules.pop("secret_math", None)Expected Output
multiply(6, 7) = 42\npower(2, 10) = 1024\nANSWER = 42\nIn sys.modules: TrueHints
Hint 1: EncryptedLoader.exec_module should: (1) get (encrypted_bytes, key) from ENCRYPTED_REGISTRY, (2) decrypt with xor_decrypt, (3) decode to str, (4) compile, (5) exec in module.__dict__.
Hint 2: compile(source_str, filename, "exec") returns a code object. exec(code, module.__dict__) runs it.
Hint 3: Use the module name as the filename in compile() so tracebacks show a useful origin.
