Capstone: A Reliable Catalog CLI
Combine validation, deterministic reporting, atomic file replacement, packaging, and user-facing acceptance checks.
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.
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.
# 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.json04Review 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.
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.
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.
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.
Explore a worked solution
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.
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.
Explore a worked solution
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.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
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.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
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 moduleOne chapter closer.
Reading is a start. Trying it yourself makes the connection.