Files
2026-06-01 21:42:05 +00:00

3.8 KiB
Raw Permalink Blame History

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

# 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 todays 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.