FreeCampus Python

Design a Package People Can Rely On

Organize related modules as a package, design stable public imports, prevent import-time surprises and cycles, and move implementation without breaking callers.
python-foundations modules-environments-projects packages public-api
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–5 hours
  • You will learn: Create a real import package, use package-relative imports, define a small public facade, recognize import side effects and cycles, and preserve callers while implementation files change.
  • Practice in: A local editor and terminal using a disposable package workspace

The constellation report now has several related modules. Shipping orbit_math.py, report_text.py, and run_report.py as unrelated top-level names risks collisions with somebody else’s files and exposes every internal location to callers. A package gives the project one import namespace. A carefully chosen package interface gives callers a smaller promise than “every name in every file will remain here forever.”

This lesson asks:

2. Use absolute imports for package identity and relative imports for nearby ownership

Within a package, these two statements may reach the same module:

# Absolute: begins from an importable top-level package.
from constellation.angles import normalize_degrees

# Explicit relative: begins from the current package.
from .angles import normalize_degrees

The first spells the full package identity and is clear across distant package areas. The second emphasizes that the dependency is a nearby sibling. Choose a consistent policy rather than mixing forms randomly.

Relative imports depend on package context. Do not run a package’s internal file as though it were an independent script:

# Fragile command: reports.py has no parent-package context here.
python constellation/reports.py

Instead, run a top-level program that imports the package, or provide a package entry module and use module mode:

python demo.py
# Later: python -m constellation

The internal module is reusable implementation, not a command merely because it ends in .py.

Import modules rather than duplicating definitions

Do not copy normalize_degrees into reports.py to avoid an import. Two copies can drift into different contracts. Keep one owner and express the dependency:

# constellation/reports.py
from . import angles


def observation_line(label, raw_angle):
    angle = angles.normalize_degrees(raw_angle)
    return f"{label.strip().title()}: {angle}°"

Importing the module rather than the selected function can make ownership more visible. Either form is valid; choose for clarity and cycle structure, not a claim that one spelling is universally faster.

3. Design a public facade deliberately

A public interface is the set of names callers are invited to rely on. It is a design and compatibility promise, not every technically reachable object.

Use three signals together:

  1. Documentation teaches the supported imports and behavior.
  2. Package-root re-exports offer short, stable paths.
  3. Naming marks implementation details with a leading underscore.
# constellation/reports.py
def _clean_label(value):
    """Normalize an internal display label."""
    return value.strip().title()


def observation_line(label, raw_angle):
    """Return one supported report line."""
    return f"{_clean_label(label)}: {raw_angle % 360}°"

The underscore does not create privacy or access control. A determined caller can still import _clean_label. It communicates “this name may change; prefer the documented public operation.”

__all__ also communicates intent and controls what a star import exports:

# constellation/__init__.py
from .angles import normalize_degrees
from .reports import observation_line

__all__ = ["normalize_degrees", "observation_line"]

It does not block access to other attributes, and it does not replace documentation. Since application code should avoid star imports, its greater value here is an inspectable list of intended exports.

Check the facade explicitly:

import constellation

print(constellation.__all__)
print(callable(constellation.normalize_degrees))
print(callable(constellation.observation_line))

Keep the public surface smaller than the implementation

Do not automatically re-export every helper. Each public name becomes a future compatibility decision. A focused package root helps beginners and tools find the supported path:

from constellation import observation_line

print(observation_line("andromeda", 725))

Callers can still import a documented submodule when that submodule itself is a public part of a larger package. “Everything must be at the root” is no better than “nothing should be at the root.” Design around coherent user tasks.

Checkpoint: identify the actual promise

4. Keep imports quiet and predictable

Importing a package should establish definitions and lightweight constants. It should not unexpectedly:

  • prompt for user input;
  • print a demonstration;
  • create or replace files;
  • connect to a remote service;
  • start a long computation; or
  • launch the command-line program.

This initializer is surprising:

# Avoid in constellation/__init__.py
from .reports import observation_line

print(observation_line("demo star", 0))

Move the program action to a callable and an execution boundary. A package can support python -m constellation through __main__.py:

# constellation/__main__.py
from .reports import observation_line


def main():
    print(observation_line("demo star", 0))


if __name__ == "__main__":
    main()

Now import constellation is quiet, while python -m constellation asks for the action. An installed console command is another entry route taught in Lesson 6. All routes should eventually delegate to one callable rather than copying program logic.

Avoid mutable package state as hidden communication

This pattern couples every caller to import order:

# Avoid as a project communication mechanism.
active_observatory = None

One module sets it; another hopes it has already been set. Prefer passing configuration or data explicitly to functions. Module constants are appropriate for fixed definitions such as FULL_CIRCLE = 360; changing session state is a different ownership problem.

Verify import behavior in a fresh process

A notebook that imported yesterday’s package object cannot prove today’s source is quiet. Run a new interpreter and capture all three process channels:

import subprocess
import sys

result = subprocess.run(
    [
        sys.executable,
        "-c",
        "import constellation; "
        "print(sorted(constellation.__all__))",
    ],
    text=True,
    capture_output=True,
)

print("return code:", result.returncode)
print("stdout lines:", result.stdout.splitlines())
print("stderr:", result.stderr)

For the package in this lesson, the return code should be zero, stderr should be empty, and stdout should contain only the one line explicitly printed by the diagnostic. A demo line appearing before it proves import-time action remains. An exception traceback on stderr identifies initialization that did not finish.

Then ask for each public operation through the advertised path:

program = (
    "from constellation import normalize_degrees, observation_line; "
    "print(normalize_degrees(-1)); "
    "print(observation_line(' vega ', 361))"
)
result = subprocess.run(
    [sys.executable, "-c", program],
    text=True,
    capture_output=True,
    check=True,
)
assert result.stdout.splitlines() == ["359", "Vega: 1°"]

This contract cares about supported imports and behavior, not whether the implementation uses a particular helper name. That distinction lets a refactor remain invisible to callers.

Treat the package facade as a dependency direction

It is tempting to make internal modules import names back from the root:

# Avoid inside constellation/reports.py:
# from constellation import normalize_degrees

The package root is still initializing and imports reports.py; reports.py then asks the unfinished root for a re-export. Instead, internal modules import their actual lower-level owner (.angles or .calculations). The root facade depends on internals, not the other way around.

Annotate the package before refactoring:

File Defines Imports from Re-exported at root?
calculations.py normalize_degrees nothing internal yes
reports.py observation_line calculations.py yes
__init__.py public facade both modules not applicable
__main__.py execution boundary command/report layer no

If an arrow points upward from calculations to reports or from an internal module to the facade, ask which responsibility is misplaced. The table is more useful than moving an import into a function solely to delay the same cycle.

5. Break circular dependencies by moving shared direction downward

A circular import occurs when initialization returns to a module that has not finished defining the needed names. Consider this dependency:

reports.py  ->  labels.py
    ^              |
    |______________|

reports.py imports clean_label from labels.py, while labels.py imports observation_line from reports.py. Whichever starts first encounters a partially initialized partner.

The repair is usually architectural, not “put imports randomly inside functions.” Find the shared lower-level responsibility:

labels.py       angles.py
     \           /
      \         /
       reports.py
# constellation/labels.py
def clean_label(value):
    """Return a trimmed display label."""
    return value.strip().title()
# constellation/reports.py
from .angles import normalize_degrees
from .labels import clean_label


def observation_line(label, raw_angle):
    """Return one display line using lower-level helpers."""
    return f"{clean_label(label)}: {normalize_degrees(raw_angle)}°"

Both helpers point toward report composition; neither needs to import the higher-level report. This one-directional dependency is easier to initialize, understand, and change.

NoteRead the earliest useful traceback line

Messages about a “partially initialized module” or a name missing during import often indicate a cycle. Draw module-to-module arrows from the import statements, then move genuinely shared behavior to a lower-level owner. Renaming imports or retrying usually does not remove the cycle.

6. Import-package names and distribution names serve different users

The source directory here is named constellation, so callers write import constellation. In Lesson 6, a distribution might be named constellation-report in pyproject.toml, because distribution names often use hyphens and describe the installable project.

Distribution metadata: constellation-report 0.1.0
Import package:         constellation
Module:                 constellation.reports
Public callable:        constellation.observation_line

These are related identities, not interchangeable spellings. One distribution can install multiple import packages; an import package can contain many modules. Write installation and metadata instructions with the distribution name, and Python examples with the import name.

7. Move an implementation file without breaking callers

Start with the package from Section 1. The public contract is:

from constellation import normalize_degrees, observation_line

assert normalize_degrees(-1) == 359
assert observation_line(" vega ", 361) == "Vega: 1°"

Now replace angles.py with an internal calculations.py:

constellation/
├── __init__.py
├── calculations.py
└── reports.py
# constellation/calculations.py
FULL_CIRCLE = 360


def normalize_degrees(value):
    """Return an angle in the range 0 <= result < 360."""
    return value % FULL_CIRCLE

Update internal imports and keep the root facade:

# constellation/reports.py
from .calculations import normalize_degrees


def observation_line(label, raw_angle):
    """Return one normalized constellation observation line."""
    angle = normalize_degrees(raw_angle)
    return f"{label.strip().title()}: {angle}°"
# constellation/__init__.py
from .calculations import normalize_degrees
from .reports import observation_line

__all__ = ["normalize_degrees", "observation_line"]

Rerun the public contract unchanged. A caller that used from constellation.angles import normalize_degrees would break because it coupled itself to the old implementation file. A caller using the advertised facade remains valid.

Next, add format_observations(observations) to reports.py. It should return newline-separated observation_line results. Export it only after its name, input shape, return type, and empty-input behavior are deliberate.

Compare one package extension after making your own
# constellation/reports.py
from .calculations import normalize_degrees


def observation_line(label, raw_angle):
    """Return one normalized constellation observation line."""
    angle = normalize_degrees(raw_angle)
    return f"{label.strip().title()}: {angle}°"


def format_observations(observations):
    """Return newline-separated lines for `(label, angle)` pairs."""
    return "\n".join(
        observation_line(label, angle)
        for label, angle in observations
    )
# constellation/__init__.py
from .calculations import normalize_degrees
from .reports import format_observations, observation_line

__all__ = [
    "format_observations",
    "normalize_degrees",
    "observation_line",
]
from constellation import format_observations

assert format_observations([]) == ""
assert format_observations([("lyra", -1), ("orion", 360)]) == (
    "Lyra: 359°\nOrion: 0°"
)

The public name is added in one visible place. Its implementation still depends downward on calculations rather than creating a reverse import.

Checkpoint: keep the package dependable while it changes

Review the facade as a future caller

Before declaring the package finished, open a fresh Python process and answer these questions without reading internal files first:

  1. Can a caller discover the supported operations from package documentation and __all__?
  2. Do the root names use task language rather than internal file names?
  3. Does importing the root or any advertised submodule remain quiet?
  4. Do public functions keep their contract when implementation modules move?
  5. Does python -m constellation delegate to one command owner rather than duplicating package logic?

Create a caller-only check:

import subprocess
import sys
from tempfile import TemporaryDirectory

with TemporaryDirectory() as outside_directory:
    result = subprocess.run(
        [
            sys.executable,
            "-c",
            "import constellation; "
            "assert set(constellation.__all__) == "
            "{'normalize_degrees', 'observation_line'}; "
            "print(constellation.observation_line('lyra', -15))",
        ],
        cwd=outside_directory,
        text=True,
        capture_output=True,
        check=True,
    )
    assert result.stdout == "Lyra: 345°\n"
    assert result.stderr == ""

This proof assumes the package is installed in the selected interpreter, which Lessons 5–6 make explicit. The unrelated working directory prevents a local constellation/ folder from becoming the accidental provider.

Now imagine changing _clean_label to two helpers. The caller check should not change. If it does, an implementation detail escaped into the advertised contract. Conversely, adding a genuinely supported formatter should begin with its caller example, input/return/failure decisions, and root export—not with an automatic re-export of every new definition.

Review error behavior too. A facade should not catch every internal exception merely to look stable. Preserve meaningful contract errors, add context only where the package understands it, and avoid exposing an internal filename as the only explanation users receive. Stability covers documented behavior and failure expectations, not just whether an import statement still parses. Run the caller proof after every internal move, not only before release.

8. Key points for dependable packages

  • A package groups related modules under one import namespace; this course uses an explicit __init__.py for a clear beginner structure.
  • Explicit relative imports show nearby package ownership. Absolute imports show the full top-level package identity.
  • Package initialization happens during imports, so keep __init__.py lightweight and free of unrelated action.
  • A facade re-exports selected supported names. Leading underscores and __all__ communicate intent but do not enforce privacy.
  • Public interfaces should be useful and small enough to support deliberately.
  • Repair import cycles by clarifying responsibility and dependency direction, not by scattering delayed imports without understanding the design.
  • Distribution, import-package, module, and callable names are related but distinct identities.
  • A stable root import lets implementation files move without forcing every caller to change.

References

Back to top