JEPA4Japan · tutorials

Build a Command-Line Package

1,281 words 6 min read #Python

Turn a collection of modules into an installable command with a stable interface.

Course progress Course outline 24 of 24 lessons available

Three labels, not one “package”

A reusable command has three different labels. Mixing them up makes packaging feel magical; separating them makes it ordinary.

  1. Import package import reading_report
  2. Distribution reading-report is installed
  3. Command study-report is typed
Python imports code, an installer records a distribution, and a shell launches a command.

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"
  1. Frontend pip or build asks for work
  2. [build-system] names required build tools
  3. Backend setuptools builds the artifact
  4. [project] describes the distribution
A build frontend asks the declared backend; project metadata describes what it is building.

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:

  1. Parse strings become checked values
  2. Domain calculate without a terminal
  3. Present turn results into text
Parsing, domain work, and presentation stay independently testable.

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

  1. Rename all three doors. Design distribution reading-tools, import package reading_tools, and command reading-summary; write the matching script entry.
  2. Add --heading. Pass it from parsing to presentation without moving formatting into cli.py; test default and custom text.
  3. 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, and unittest.
  • 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.