CHAPTER 57 · ADVANCED4 min read

Capstone: A Reliable Catalog CLI

Combine validation, deterministic reporting, atomic file replacement, packaging, and user-facing acceptance checks.

Python 3.12+Free to readModule 13 of 13
YOUR NEXT SMALL WINS

By the end, you’ll be able to…

  • Deliver a CLI with stable JSON output and meaningful exit codes.
  • Validate a bounded catalog and write reports atomically.
  • State acceptance criteria and remaining operational requirements.

01Set a concrete delivery contract

Build catalog-report, a local reporting command that reads a UTF-8 JSON array of course records. Each record has a positive integer id, a title of one to eighty trimmed characters, and nonnegative integer minutes. Reject duplicate IDs, boolean numbers, inputs over one megabyte, and more than one thousand records. The output contains schema_version, count, total_minutes, and courses sorted by ID.

The command accepts an input path and optional --output path. Without --output, emit one JSON object on stdout. With it, replace the destination only after validation and complete serialization. Expected file or data failures return status 1 with a concise stderr diagnostic; invalid command syntax uses argparse’s status 2. This is a production-minded learning capstone with explicit requirements, rather than a certification that the tool is ready for every deployment.

02Implement the representative CLI

Save this complete standard-library implementation as catalog_cli.py. The main function accepts argv for direct tests, while the guarded entry point uses real process arguments. A byte-limited read bounds input before JSON parsing. The validator creates fresh normalized records, and report construction orders them explicitly. No network access, credentials, or external package is needed to run the command.

For file output, create a temporary file in the destination directory, flush and close it, then use os.replace. Keeping the temporary file on the same filesystem supports atomic replacement semantics when the platform provides them. This avoids truncating a previous report on a validation failure. It does not provide a complete durability guarantee against power loss or solve concurrent-writer coordination.

catalog_cli.pyPython
import argparseimport jsonimport osfrom pathlib import Pathimport sysimport tempfileMAX_BYTES = 1_000_000MAX_RECORDS = 1000def read_courses(path):    with path.open('rb') as stream:        payload = stream.read(MAX_BYTES + 1)    if len(payload) > MAX_BYTES:        raise ValueError('input exceeds byte limit')    data = json.loads(payload.decode('utf-8'))    if not isinstance(data, list) or len(data) > MAX_RECORDS:        raise ValueError('expected an array of at most 1000 courses')    courses, seen = [], set()    for number, item in enumerate(data, 1):        if not isinstance(item, dict):            raise ValueError(f'record {number}: expected object')        identifier = item.get('id')        title, minutes = item.get('title'), item.get('minutes')        if type(identifier) is not int or identifier < 1 or identifier in seen:            raise ValueError(f'record {number}: invalid or duplicate id')        if not isinstance(title, str) or not 1 <= len(title.strip()) <= 80:            raise ValueError(f'record {number}: invalid title')        if type(minutes) is not int or minutes < 0:            raise ValueError(f'record {number}: invalid minutes')        seen.add(identifier)        courses.append({'id': identifier, 'title': title.strip(), 'minutes': minutes})    return sorted(courses, key=lambda item: item['id'])def make_report(courses):    return {'schema_version': 1, 'count': len(courses),            'total_minutes': sum(item['minutes'] for item in courses),            'courses': courses}def atomic_write(destination, text):    temporary = None    try:        with tempfile.NamedTemporaryFile(                mode='w', encoding='utf-8', newline='\n', delete=False,                dir=destination.parent, prefix='.catalog-', suffix='.tmp') as stream:            temporary = Path(stream.name)            stream.write(text)            stream.flush()        os.replace(temporary, destination)        temporary = None    finally:        if temporary is not None:            temporary.unlink(missing_ok=True)def main(argv=None):    parser = argparse.ArgumentParser(        prog='catalog-report', description='Validate and summarize a course catalog.',        allow_abbrev=False)    parser.add_argument('input', type=Path, help='UTF-8 JSON catalog')    parser.add_argument('--output', type=Path, help='replace this report file')    args = parser.parse_args(argv)    try:        if args.output is not None and args.input.resolve() == args.output.resolve():            raise ValueError('input and output must be different paths')        report = make_report(read_courses(args.input))        text = json.dumps(report, ensure_ascii=False, sort_keys=True) + '\n'        if args.output is None:            sys.stdout.write(text)        else:            atomic_write(args.output, text)    except (OSError, ValueError, RecursionError) as error:        # Do not echo source payloads or credential-bearing paths.        if isinstance(error, OSError):            message = 'file operation failed'        elif isinstance(error, (json.JSONDecodeError, UnicodeDecodeError, RecursionError)):            message = 'input is not valid UTF-8 catalog JSON'        else:            message = str(error)        print(f'catalog-report: {message}', file=sys.stderr)        return 1    return 0if __name__ == '__main__':    raise SystemExit(main())

03Run acceptance checks against the command

Create a fixture containing SQL with ID 2 and ninety minutes, followed by Python with ID 1 and one hundred twenty minutes. The JSON report must show count two, total_minutes two hundred ten, schema_version one, and IDs in the order one then two. Run it twice and compare output bytes. Include Unicode titles and paths containing spaces in the acceptance suite.

Write an existing output file containing a marker, then pass a malformed input with --output targeting that file. Require status 1, a useful stderr message, empty stdout, the original marker unchanged, and no leftover temporary files. Also test an unreadable input, empty array, duplicate IDs, boolean minutes, oversized input, and missing output directory. Package the functions in catalog_report.cli and run the same checks against the installed wheel entry point.

capstone-commands.txtShell
# Create fixture.json containing:# [{"id":2,"title":"SQL","minutes":90},{"id":1,"title":"Python","minutes":120}]python catalog_cli.py fixture.jsonpython catalog_cli.py fixture.json --output report.jsonpython catalog_cli.py --help# Invalid JSON or a missing input should produce exit status 1:python catalog_cli.py missing.json

04Review limits and choose extensions

Review the implementation against every acceptance criterion before adding features. The program accepts an incremental snapshot for reporting, not arbitrary remote execution. It has bounded input and deterministic output, but a deployed version still needs permissions, release policy, dependency and interpreter updates, backup expectations, and review of who may select input and output paths. Atomic replacement does not define an application-wide authorization policy.

Next, reuse the earlier pipeline’s transport and SQLite adapters behind explicit subcommands, add schema migration tests, and define backward compatibility for the report format. If reliability requirements demand crash durability, investigate filesystem-specific synchronization and recovery. If concurrency is required, assign one owner to report publication. Keep an extension only when it has a user requirement, a failure contract, and an acceptance check that explains when it works.

LET IT CLICK

The ideas worth keeping.

  • Deliver a concrete CLI contract with stable output and failure codes.
  • Validate and prepare output before replacing existing files.
  • Treat packaging, acceptance checks, and operational limits as part of delivery.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

Make it yours.

Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.

01

Acceptance-test report content

Implement a pure report builder for validated records. Sort by ID and prove count, total, and schema version on an unordered fixture.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
def build_report(courses):    ordered = sorted(courses, key=lambda item: item['id'])    return {'schema_version': 1, 'count': len(ordered),            'total_minutes': sum(item['minutes'] for item in ordered),            'courses': ordered}report = build_report([    {'id': 2, 'title': 'SQL', 'minutes': 90},    {'id': 1, 'title': 'Python', 'minutes': 120},])assert report['schema_version'] == 1assert report['count'] == 2 and report['total_minutes'] == 210assert [item['id'] for item in report['courses']] == [1, 2]

The builder assumes validated records and keeps presentation deterministic. Validation and transport tests remain separate responsibilities.

02

Prove old output survives validation failure

Create a temporary report containing a marker. Validate a rejected fixture before attempting a write, then prove the existing report is unchanged.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from pathlib import Pathimport tempfiledef publish_validated(value, destination):    if not isinstance(value, list):        raise ValueError('expected a catalog array')    destination.write_text('validated report', encoding='utf-8')with tempfile.TemporaryDirectory() as directory:    destination = Path(directory) / 'report.json'    destination.write_text('previous report', encoding='utf-8')    try:        publish_validated({'wrong': 'shape'}, destination)    except ValueError:        pass    assert destination.read_text(encoding='utf-8') == 'previous report'

This focused test proves validation precedes mutation. The full capstone additionally uses atomic replacement and needs separate checks for write failures, temporary-file cleanup, and real command exit status.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01Why serialize the complete report before replacing a destination?
02Why include schema_version in machine-readable output?
03What does a completed capstone justify claiming?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

Acceptance-test report content

Implement a pure report builder for validated records. Sort by ID and prove count, total, and schema version on an unordered fixture.

EXPECTED OUTPUT
[Process exited with code 0 - No output]
challenge.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this chapter, when you want a second angle or the full details.

MODULE 13 · PRACTICAL SELF-CHECK

Pause. Put the pieces together.

You’ve reached the end of Professional Python Projects. Before moving on, use this task to check what you can do independently.

Install the built wheel outside the source checkout and verify success, usage errors, and operational failures.

Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.

Review this module

One chapter closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock chapter completion.