3.8 KiB
Module Design
Design a small, stable public surface. Keep implementation modules movable behind it.
Why
Python makes it easy to publish internals by accident:
- every file is importable
- helpers become de facto API once users depend on them
- refactors turn into breaking changes when file layout becomes the contract
Mature libraries push back on that. They usually:
- publish one obvious import surface
- re-export supported names deliberately
- keep internal modules non-authoritative
- use
__all__when the boundary needs to be explicit
That buys two things:
- simpler imports for callers
- freedom to reorganize internals later
The pattern
- Decide what callers should import.
- Re-export those names from the package boundary.
- Keep implementation details in internal modules.
- Use
__all__when you want an explicit contract. - Treat internal file layout as private unless you intentionally publish it.
When to use
Use this when:
- a package spans multiple modules
- internals will evolve faster than the public API
- you want callers thinking in domain concepts, not filenames
- compatibility matters across releases
When not to use
Do not build a facade when:
- the package is tiny and direct imports are already clear
- the abstraction boundary is still moving fast
- re-exporting would turn
__init__.pyinto a junk drawer
A curated surface is not the same as a flat surface. Keep structure where the concepts are meaningfully different.
Preferred shapes
Package facade over internal modules
# mypkg/__init__.py
from ._client import Client
from ._errors import AppError
from ._models import Item
__all__ = ["Client", "AppError", "Item"]
Why this works:
- callers learn one stable import path
- internals can move without import churn
- the package advertises its real contract
Explicit module contract
__all__ = ["Number", "Complex", "Real", "Rational", "Integral"]
This says: these names are supported; everything else is implementation detail.
Counterexamples
File layout becomes the API by accident
from mypkg.utils import helper_a
from mypkg.impl_v2 import thing
from mypkg.more_helpers import other_thing
Refactoring internal modules now breaks users.
Everything dumped into __init__.py
If __init__.py exports fifty unrelated names, you did not create a clean facade. You created autocomplete noise.
Public API mirrors the folder tree too literally
If callers need to know today’s internal layout to use the library, the boundary is still underdesigned.
Source signals
CPython
Lib/numbers.py:8-23warns that published ABC APIs are hard to change and should be designed carefully.Lib/numbers.py:35publishes a narrow__all__rather than treating every helper as public.Lib/operator.py:13-15andLib/smtplib.py:55-58do the same in stdlib modules with mixed public/internal names.
Pytest
src/pytest/__init__.py:6-80builds the top-levelpytestAPI by importing from many_pytest.*internals.src/pytest/__init__.py:98-186then pins that facade with an explicit__all__.
HTTPX
httpx/__init__.py:1-12re-exports the package surface from internal modules such as._client,._exceptions, and._models.httpx/__init__.py:29-100defines the supported top-level export list explicitly.httpx/__init__.py:103-106rewrites exported objects’__module__tohttpx, reinforcing the facade instead of leaking internal filenames.
Click
src/click/__init__.py:10-75exposes the package through re-exports.src/click/__init__.py:77-126keeps compatibility shims and deprecations at the boundary instead of freezing old internal layout forever.
Bottom line
Make the public API intentional.
Callers should depend on your concepts, not your current file tree.