128 lines
3.8 KiB
Markdown
128 lines
3.8 KiB
Markdown
# 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
|
||
|
||
1. Decide what callers should import.
|
||
2. Re-export those names from the package boundary.
|
||
3. Keep implementation details in internal modules.
|
||
4. Use `__all__` when you want an explicit contract.
|
||
5. 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__.py` into 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
|
||
|
||
```python
|
||
# 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
|
||
|
||
```python
|
||
__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
|
||
|
||
```python
|
||
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-23` warns that published ABC APIs are hard to change and should be designed carefully.
|
||
- `Lib/numbers.py:35` publishes a narrow `__all__` rather than treating every helper as public.
|
||
- `Lib/operator.py:13-15` and `Lib/smtplib.py:55-58` do the same in stdlib modules with mixed public/internal names.
|
||
|
||
### Pytest
|
||
|
||
- `src/pytest/__init__.py:6-80` builds the top-level `pytest` API by importing from many `_pytest.*` internals.
|
||
- `src/pytest/__init__.py:98-186` then pins that facade with an explicit `__all__`.
|
||
|
||
### HTTPX
|
||
|
||
- `httpx/__init__.py:1-12` re-exports the package surface from internal modules such as `._client`, `._exceptions`, and `._models`.
|
||
- `httpx/__init__.py:29-100` defines the supported top-level export list explicitly.
|
||
- `httpx/__init__.py:103-106` rewrites exported objects’ `__module__` to `httpx`, reinforcing the facade instead of leaking internal filenames.
|
||
|
||
### Click
|
||
|
||
- `src/click/__init__.py:10-75` exposes the package through re-exports.
|
||
- `src/click/__init__.py:77-126` keeps 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.
|