Modules, packages, and imports
Understand import execution, package boundaries, __init__.py, __all__, re-exports, and stable public APIs.
An import is not textual inclusion. Python finds a module, creates a module
object, executes its top-level code, caches the object in sys.modules, and
binds a name in the importing scope. This model explains import-time side
effects, partial modules during circular imports, and why repeated imports
normally do not re-run code.
Binding forms
import statistics
from pathlib import Path
from collections.abc import Iterable as IterableABC
mean = statistics.mean([2, 4, 6])
home = Path.home()
assert isinstance([], IterableABC)import statistics binds the module name. from pathlib import Path binds the
selected object directly. The first form keeps provenance visible and makes name
collisions less likely; the second is useful for stable, frequently used names.
Avoid from module import *. It obscures where names originate and makes API
changes harder to review. Wildcard imports consult module.__all__ when it
exists; otherwise they import names not beginning with an underscore.
Packages and __init__.py
A regular package is a directory containing __init__.py. Importing any module
inside it first initializes parent packages. __init__.py may be empty, define
package metadata, or re-export a curated API:
acme/
├── __init__.py
├── models.py
└── parsing.py# acme/models.py
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Record:
key: str
value: str# acme/__init__.py
from .models import Record
from .parsing import parse_record
__all__ = ["Record", "parse_record"]Consumers can now write from acme import Record. This is a re-export:
Record.__module__ is still acme.models, but acme promises a convenient,
stable access path.
Keep __init__.py light. Database connections, logging configuration, network
requests, and large optional imports make startup slow and imports fragile.
Namespace packages can omit __init__.py, but their multi-directory semantics
are mainly useful for intentionally distributed packages.
What __all__ does—and does not do
__all__ documents and controls wildcard export behavior. Static documentation
tools may also use it as an API signal.
__all__ = ["parse", "ParseError"]It does not make other names private. A consumer can still explicitly import
package.internal_name. A leading underscore communicates non-public status,
but Python enforces neither convention. API stability is a maintenance promise,
not an access-control feature.
Absolute and relative imports
# Inside acme/parsing.py
from acme.models import Record # absolute
# or
from .models import Record # explicit relativeAbsolute imports are unambiguous across a large codebase. Explicit relative imports emphasize that modules belong to one package and can ease package renaming. Do not use implicit relative imports; modern Python treats an unqualified import as absolute.
Run package code with python -m acme.tools.report rather than executing a file
deep inside the package. Module execution gives Python the package context needed
to resolve relative imports.
Import cycles
Suppose orders.py imports payments.py, which imports orders.py. The second
module observes a partially initialized first module. Accessing a name that has
not yet been defined raises an error often phrased as “partially initialized
module.”
Prefer structural fixes:
- Move shared types to a lower-level module.
- Depend on a protocol rather than a concrete peer.
- Pass a dependency into a function or constructor.
- Import a module rather than copying many names between peers.
For annotations only, avoid runtime imports with TYPE_CHECKING:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from acme.payments import PaymentGateway
def submit(gateway: PaymentGateway) -> None:
gateway.charge()A local import inside a function can break a cycle or defer an expensive optional dependency, but it is a tactical tool. If ordinary modules cannot be imported without precise ordering, the dependency graph likely needs redesign.
Import-time behavior and caching
import importlib
import sys
import acme
assert sys.modules["acme"] is acme
reloaded = importlib.reload(acme)
assert reloaded is acmeReloading is mainly an interactive-development feature. Existing references to objects defined before a reload do not automatically update, so reload-based application designs become inconsistent.
Guard script-only behavior:
def main() -> int:
print("Generate report")
return 0
if __name__ == "__main__":
raise SystemExit(main())Importing this module defines main without running it. Executing it as a script
sets __name__ to "__main__".
Tradeoffs and pitfalls
- Re-exports create a friendly API but add a compatibility commitment.
- Eager imports surface missing dependencies early but increase startup cost.
- Lazy imports reduce initial work but defer failures to a later code path.
- Module-level singletons are simple but complicate tests, reloads, and process lifecycle.
- Mutating
sys.pathinside application code hides packaging errors. - Naming a local file
typing.py,json.py, oremail.pycan shadow the standard library.
Practice
- Create a package with one public class and one internal helper. Re-export only
the class and define
__all__. - Draw the import graph for three related modules. Refactor one bidirectional dependency into dependency injection or a shared protocol.
- Add a
main()entry point to an importable module and verify that importing it produces no output. - Explain why
__all__is API documentation rather than privacy enforcement.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK