Organize related modules as a package, design stable public imports, prevent import-time surprises and cycles, and move implementation without breaking callers.
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:
What turns a directory of source files into the package used here?
When should an internal import be absolute or relative?
Which imports do callers deserve to treat as stable?
What do a leading underscore and __all__ communicate—and not enforce?
How do import-time work and circular dependencies make packages fragile?
For this course, a package is a directory containing __init__.py. Python also supports namespace packages without that file, but their multi-location design is outside this foundation lesson. An explicit __init__.py gives this small project one clear initialization file.
# constellation/angles.pyFULL_CIRCLE =360def normalize_degrees(value):"""Return an angle in the range 0 <= result < 360."""return value % FULL_CIRCLE
# constellation/reports.pyfrom .angles import normalize_degreesdef observation_line(label, raw_angle):"""Return one display line with a normalized angle.""" angle = normalize_degrees(raw_angle)returnf"{label.strip().title()}: {angle}°"
The leading dot in .angles means “start in the current package.” It tells a reader that angles is a sibling owned by constellation, not an unrelated top-level package.
Expose the first public names from the package root:
When Python imports constellation.reports, it first establishes the constellation package and runs constellation/__init__.py, then initializes the requested submodule as needed. Therefore the package initializer should be lightweight. Re-exporting selected definitions is normal; reading user input or generating a report is not.
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:
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:
Documentation teaches the supported imports and behavior.
Naming marks implementation details with a leading underscore.
# constellation/reports.pydef _clean_label(value):"""Normalize an internal display label."""return value.strip().title()def observation_line(label, raw_angle):"""Return one supported report line."""returnf"{_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:
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.
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_lineprint(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.
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:
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:
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.pydef clean_label(value):"""Return a trimmed display label."""return value.strip().title()
# constellation/reports.pyfrom .angles import normalize_degreesfrom .labels import clean_labeldef observation_line(label, raw_angle):"""Return one display line using lower-level helpers."""returnf"{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.0Import package: constellationModule: constellation.reportsPublic 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:
# constellation/calculations.pyFULL_CIRCLE =360def normalize_degrees(value):"""Return an angle in the range 0 <= result < 360."""return value % FULL_CIRCLE
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.pyfrom .calculations import normalize_degreesdef observation_line(label, raw_angle):"""Return one normalized constellation observation line.""" angle = normalize_degrees(raw_angle)returnf"{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 )
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.