A script becomes difficult to maintain when importing one calculation also asks for input or rewrites a file. Modules give related functions a home; type hints communicate what those functions expect. After iteration and generators, you can build a small package whose calculation works independently of its command-line entry point.
Make imports predictable
A module is commonly a .py file. A regular package is a directory of modules containing __init__.py. Python also supports namespace packages without that file, but an explicit regular package is easier to learn with. An import executes a module's top-level statements on its first normal import in a process, so avoid doing application work there.
Create this directory structure inside your course workspace:
package-demo/
study_tools/
__init__.py
stats.py
__main__.py
Leave __init__.py empty. Put the following code in stats.py:
from collections.abc import Iterable
def mean_minutes(values: Iterable[int]) -> float | None:
total = 0
count = 0
for value in values:
total += value
count += 1
if count == 0:
return None
return total / count
Put this code in __main__.py:
from .stats import mean_minutes
def main() -> None:
result = mean_minutes([20, 30, 40])
if result is None:
print("No sessions yet")
else:
print(f"Average: {result:.1f} minutes")
if __name__ == "__main__":
main()
From package-demo, run python -m study_tools. Expect Average: 30.0 minutes. The leading dot in .stats means “from this package.” Running python study_tools/__main__.py directly loses that package context and can fail. The module tutorial explains import behavior and search paths.
The name guard runs main when the file is used as an entry point. Importing it can define functions without running that entry-point action. Imports still execute other top-level statements, so the guard is not a general ban on import side effects.
Treat annotations as a communication tool
In the signature, Iterable[int] says the function can consume a source of integers, including a list or generator. It does not require indexing or a second traversal. The result float | None means a float or an absent result. Handling None explicitly avoids treating a valid zero average as missing.
The Python runtime does not automatically enforce these annotations. An editor or a separately installed static type checker can flag many incompatible calls before execution. This course keeps its runnable examples in the standard library, so no checker installation is required. The typing reference explains the distinction.
Use list[str] for a list of strings and dict[str, int] for string keys with integer values. object says a value could be any object but must be inspected before specialized use. Any relaxes checking and can hide mistakes; use it thoughtfully around genuinely unknown interfaces. For external data, validate at runtime before passing a value into a typed calculation.
For example, this standalone boundary converts unknown input into a useful integer:
def require_minutes(value: object) -> int:
if type(value) is not int or value < 0:
raise ValueError("expected non-negative integer minutes")
return value
print(require_minutes(25))
It prints 25. Calling it with True, "25", or -1 raises ValueError. Hints describe the contract; this explicit check enforces it for callers that do not follow the hints.
Imports and installable projects are different
An import package is not the same concept as a distribution installed with pip, and their names can differ. Our local package works because its parent directory is on Python's search path. You do not need to edit sys.path or publish it to PyPI.
When you actually need an installable project, pyproject.toml records build configuration and project metadata, including supported Python versions and dependencies. The PyPA packaging tutorial walks through that separate step. Do not assume that simply adding a metadata file makes any directory layout installable.
Pitfalls and practice
Avoid filenames such as json.py, typing.py, or unittest.py, which can shadow standard-library modules. Circular imports often indicate that two modules need a smaller shared dependency or a clearer responsibility.
Exercise: add a total_minutes function in stats.py, import it in the entry point, and print the total as well as the average. Then call mean_minutes with an empty list.
Solution guidance: accept an iterable, consume it once, and return an integer total. The sample total is 90; the empty average is None. Keep printing in __main__.py so importing stats remains useful to a future test.

