Course progress Course outline 24 of 24 lessons available
Python Foundations
Data and Collections
Building Reliable Programs
Modeling with Objects
Professional Python
Advanced Python
Three labels, not one “package”
A reusable command has three different labels. Mixing them up makes packaging feel magical; separating them makes it ordinary.
-
Import package
import reading_report -
Distribution
reading-reportis installed -
Command
study-reportis typed
The names may match, but do not have to. One distribution can contain several import packages and commands. A module is one .py file inside or outside an import package.
Draw the project map and build contract
A src layout keeps importable code away from the repository root:
reading-report/
├── pyproject.toml
├── README.md
├── src/reading_report/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── domain.py
│ └── presentation.py
└── tests/test_cli.py
This catches accidental imports that work only because the current directory happens to contain the package. Tests should use an installed project, or deliberately set PYTHONPATH=src for a pre-install check.
pyproject.toml contains two different contracts:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "reading-report"
version = "0.1.0"
description = "Summarize study sessions"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
study-report = "reading_report.cli:main"
- Frontend pip or build asks for work
- [build-system] names required build tools
- Backend setuptools builds the artifact
- [project] describes the distribution
Build requirements are not runtime dependencies. requires-python states compatibility; it does not install Python. [project.scripts] means “import reading_report.cli, find the callable main, and call it.” Installation generates the platform launcher.
Keep the command door thin
Terminal text should travel through three small rooms:
- Parse strings become checked values
- Domain calculate without a terminal
- Present turn results into text
main(argv=None) parses inside the function, prints the presentation, and returns an integer. Zero means success. At the outer boundary, __main__.py or an installed launcher turns that return value into a process status. Domain functions should not call sys.exit().
Before installation, python -m can run the same package entry point while preserving relative imports:
PYTHONPATH=src python -m reading_report Python:45 Reading:20 --goal 60
Build the complete study-report project
Put this in src/reading_report/domain.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class Session:
topic: str
minutes: int
def __post_init__(self):
topic = self.topic.strip()
if not topic:
raise ValueError("topic must not be empty")
if isinstance(self.minutes, bool) or not isinstance(self.minutes, int):
raise ValueError("minutes must be an integer")
if self.minutes < 0:
raise ValueError("minutes must not be negative")
object.__setattr__(self, "topic", topic)
def total_minutes(sessions):
return sum(session.minutes for session in sessions)
Put this in src/reading_report/presentation.py:
from .domain import total_minutes
def render_report(sessions, goal):
total = total_minutes(sessions)
status = "met" if total >= goal else "not met"
lines = ["Study report"]
lines.extend(f"- {item.topic}: {item.minutes} minutes" for item in sessions)
lines.append(f"Total: {total} minutes")
lines.append(f"Goal: {status} ({goal} minutes)")
return "\n".join(lines)
Put this in src/reading_report/cli.py:
import argparse
from .domain import Session
from .presentation import render_report
def nonnegative_int(text):
try:
number = int(text)
except ValueError as error:
raise argparse.ArgumentTypeError("must be an integer") from error
if number < 0:
raise argparse.ArgumentTypeError("must not be negative")
return number
def parse_session(text):
topic, separator, minutes = text.partition(":")
if not separator:
raise argparse.ArgumentTypeError("use TOPIC:MINUTES")
try:
return Session(topic, nonnegative_int(minutes))
except (ValueError, argparse.ArgumentTypeError) as error:
raise argparse.ArgumentTypeError(str(error)) from error
def main(argv=None):
parser = argparse.ArgumentParser(prog="study-report")
parser.add_argument("sessions", nargs="+", type=parse_session)
parser.add_argument("--goal", type=nonnegative_int, default=60)
args = parser.parse_args(argv)
print(render_report(args.sessions, args.goal))
return 0
Use these small boundary files:
# src/reading_report/__init__.py
from .domain import Session, total_minutes
__all__ = ["Session", "total_minutes"]
# src/reading_report/__main__.py
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
Create tests/test_cli.py:
import contextlib
import io
import unittest
from reading_report.cli import main, parse_session
from reading_report.domain import Session, total_minutes
class CommandTests(unittest.TestCase):
def test_domain(self):
self.assertEqual(total_minutes([Session("Python", 30)]), 30)
def test_parse(self):
self.assertEqual(parse_session(" Python :25"), Session("Python", 25))
def test_main(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(["Python:45", "Reading:20", "--goal", "60"])
self.assertEqual(status, 0)
self.assertIn("Goal: met (60 minutes)", output.getvalue())
if __name__ == "__main__":
unittest.main()
Run the command and tests with PYTHONPATH=src. Verify Total: 65 minutes, Goal: met (60 minutes), three ok results, and final OK.
Develop, build, and publish deliberately
Use a project-specific environment and an editable install during development:
python -m venv .venv
source .venv/bin/activate
python -m pip install --editable .
python -m unittest discover -s tests -v
Windows PowerShell activation is .venv\Scripts\Activate.ps1. Editable imports point at src; metadata changes such as a new script may require reinstalling. Ignore .venv/, build/, dist/, *.egg-info/, caches, and coverage output.
Building and publishing are explicit external actions. The following commands require separately installed frontends, reviewed metadata, selected credentials, and deliberate authorization; they are documentation here, not steps performed by this tutorial:
python -m build
python -m twine upload dist/*
Inspect the wheel and source archive, install the wheel in a fresh environment, rerun tests and the command, and confirm name, version, README, license, supported Python, and repository tag before any upload.
Three tiny missions and the handoff
- Rename all three doors. Design distribution
reading-tools, import packagereading_tools, and commandreading-summary; write the matching script entry. - Add
--heading. Pass it from parsing to presentation without moving formatting intocli.py; test default and custom text. - Test bad input. Check a missing colon, blank topic, nonnumeric minutes, and negative goal. Confirm concise diagnostics and a nonzero parser status.
Sharp corners: a root-only import hides layout mistakes, metadata changes may need reinstalling, parser errors leave with status 2, and uploading is a lasting external action.
- I can distinguish module, import package, distribution, and command.
- I can explain build frontend, backend, and project metadata.
- I can map a script name to a callable.
- I keep parsing, domain work, and presentation separate.
- I can use
python -m, a venv, editable install, andunittest. - I know generated artifacts are not source or permission to publish.
- I completed the three tiny missions.
Next, you will learn how Python syntax asks your own objects to represent, compare, hash, contain, and iterate.