flowchart TD A["tangled_main"] --> B["sys.argv and argparse"] A --> C["os.environ"] A --> D["filesystem"] A --> E["global logging"] A --> F["record rules"] A --> G["JSON and stdout"]
Keep the Command Thin and the Core Reusable
The trail-report behavior now has a command grammar, clean streams, validated settings, and useful logging. It can still become difficult to change if all of those responsibilities live inside one function.
This lesson assembles the pieces as a small application. The command boundary will remain thin: translate process-facing details into ordinary values, call reusable code, render the result, and translate anticipated failures back into diagnostics and status.
As you work, answer these questions:
- Which responsibilities belong in the parser, settings resolver, resource boundary, core, renderer, and
main()? - Why should core functions not accept an argparse
Namespace? - How can
python -m trail_reportand an installedtrail-reportcommand call the same implementation? - What can a direct check prove, and what requires a real child process?
- When would argparse, Click, Typer, or Rich improve the product?
1. Annotate a tangled command before changing it
This function is valid Python and may even work for the first demo:
import argparse
import json
import logging
import os
from pathlib import Path
def tangled_main():
parser = argparse.ArgumentParser()
parser.add_argument("input")
parser.add_argument("--status", default=os.environ.get("TRAIL_STATUS", "closed"))
arguments = parser.parse_args()
logging.basicConfig(level=logging.INFO)
text = Path(arguments.input).read_text(encoding="utf-8")
for line in text.splitlines():
checkpoint, status, note = line.split("|", maxsplit=2)
if status == arguments.status:
print(json.dumps({"checkpoint": checkpoint, "note": note}))
return 0Before refactoring, annotate what each line reads, decides, and changes:
| Responsibility | Hidden dependency or effect |
|---|---|
| define and parse command grammar | real sys.argv; argparse exits on errors |
| choose a status | process environment read during parser construction |
| configure logging | global root handlers |
| acquire input | filesystem and current directory |
| parse and select records | mixed with adapters and rendering |
| render JSON | real stdout |
| return status | no translation of anticipated failures |
The problem is not that any one library is “bad.” The function has too many reasons to change. A new TOML rule, alternate renderer, notebook caller, or file error all require editing the same block.
The tangled function depends directly on every external boundary, so the core behavior cannot be called without constructing process state.
Make one controlled change
Imagine a notebook needs to summarize a list already in memory. It should not invent an input filename, patch sys.argv, alter the process environment, or capture global stdout merely to reuse the selection rule. That requirement reveals the first useful extraction: the core should accept records and ordinary settings, then return ordinary result values.
2. Give each boundary one job
A small CLI does not need dozens of layers. It needs enough separation that each function has a clear contract:
build_parser()describes command syntax and help;resolve_settings()converts raw configuration into validated settings;open_input()owns files or selects stdin;parse_records()turns external lines into domain records;select_records()performs reusable application behavior;render_records()writes the documented output format;main()coordinates adapters, translates anticipated failures, and returns status; and- the entry point calls
raise SystemExit(main()).
The names can vary. The direction of dependency matters more: core functions should not import argparse, read os.environ, open global streams, configure logging, or terminate the process.
External details point inward through adapters. The reusable core receives ordinary values and returns ordinary values; it does not point back to argparse or process globals.
flowchart LR A["argv"] --> B["parser"] C["TOML and environment"] --> D["settings resolver"] E["file or stdin"] --> F["record parser"] B --> G["main boundary"] D --> G F --> H["reusable core"] G --> H H --> I["renderer"] I --> J["stdout"] G --> K["stderr and status"]
This idea is sometimes called a functional core with an imperative shell. In ordinary language: keep the decisions as functions over explicit values; keep files, streams, environment state, logging setup, and process termination near the outside. The core does not have to be mathematically pure, but hidden I/O should not be required for an ordinary calculation.
Checkpoint: place each responsibility
3. Pass domain values instead of a Namespace
An argparse namespace belongs to the CLI adapter:
A caller must construct an object with attributes named exactly like CLI destinations. Renaming --status to --state could break core code even if the domain concept did not change.
Pass ordinary parameters or a settings value:
The adapter translates names explicitly:
That small repetition is valuable. It states which command inputs enter the core and prevents parser-only fields such as handler, command, or input from leaking across the boundary.
Return results before rendering them
When feasible, the core returns records rather than printing them:
One renderer can write JSON and another can write a human table. Changing presentation does not change counting. For very large streams, returning a generator or accepting a result writer may be appropriate, but begin with the smallest interface that meets real size requirements.
4. Assemble one small package
An intentional package tree might be:
Responsibilities remain easy to state:
records.py: parse, validate, select, and summarize ordinary records;config.py: load and resolve settings;render.py: serialize results to a supplied stream;cli.py: parser, resources, logging setup,main(); and__main__.py: process entry adapter.
A reusable record module
def parse_record(line):
parts = [part.strip() for part in line.split("|", maxsplit=2)]
if len(parts) != 3:
raise ValueError("expected CHECKPOINT|STATUS|NOTE")
checkpoint, status, note = parts
if not checkpoint or status not in {"open", "limited", "closed"} or not note:
raise ValueError("invalid checkpoint, status, or note")
return {"checkpoint": checkpoint, "status": status, "note": note}
def select_status(records, status, limit):
return [record for record in records if record["status"] == status][:limit]No CLI import appears. A notebook can call both functions naturally.
A renderer with an explicit destination
The renderer owns JSON spelling and newlines, but not record selection.
A parser factory
import argparse
def build_parser():
parser = argparse.ArgumentParser(
prog="trail-report",
description="Select trail reports without polluting structured output.",
)
parser.add_argument("input", metavar="INPUT")
parser.add_argument(
"--status",
choices=("open", "limited", "closed"),
default=None,
)
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("-v", "--verbose", action="count", default=0)
return parserThe settings lesson explains why configurable values default to None: an omitted CLI option must not erase weaker configuration.
One explicit coordinator
import sys
def main(argv=None, stdin=None, stdout=None, stderr=None, environment=None):
if stdin is None:
stdin = sys.stdin
if stdout is None:
stdout = sys.stdout
if stderr is None:
stderr = sys.stderr
if environment is None:
environment = dict()
arguments = build_parser().parse_args(argv)
status = arguments.status or environment.get("TRAIL_REPORT_STATUS", "closed")
limit = arguments.limit if arguments.limit is not None else 20
try:
records = [parse_record(line) for line in stdin if line.strip()]
selected = select_status(records, status, limit)
except ValueError as error:
print(f"trail-report: {error}", file=stderr)
return 1
write_jsonl(selected, stdout)
return 0This compact snapshot omits the full TOML and file-input code to keep the architecture visible. Its important boundary is explicit. In the synthesis lab, restore the complete settings resolver and open_input() behavior from the earlier lessons.
Do not use arguments.status or ... when empty string is meaningful. Here argparse choices prevent an explicit empty status, so the expression is safe; the more general resolver should still test is not None.
5. Connect two entry routes to the same main()
__main__.py supports module execution:
Now this invocation reaches the same coordinator:
Unit 10 introduced project scripts. In pyproject.toml, an installed console entry point can name the callable:
An installer creates the platform-appropriate wrapper. The callable returns an integer; the wrapper translates it to process status. Do not create a second implementation for the installed route.
The two routes are valuable evidence:
python -m trail_reportchecks the package’s module entry point; andtrail-reportchecks the installed project-script metadata and wrapper.
They should expose the same parser, core, output, and status policy.
Checkpoint: choose direct or process evidence
6. Check each layer before the testing unit
Unit 13 will organize checks with pytest. Here we need enough immediate evidence to know the CLI was assembled correctly.
Check the core directly
The second assertion documents how slicing treats a zero limit. If the public settings contract rejects zero, test that validation separately rather than pretending the selection algorithm cannot receive it.
Check main() with injected collaborators
import io
stdin = io.StringIO("north-ridge|open|Windy\n")
stdout = io.StringIO()
stderr = io.StringIO()
status = main(
["-", "--status", "open", "--limit", "5"],
stdin=stdin,
stdout=stdout,
stderr=stderr,
environment={},
)
assert status == 0
assert '"checkpoint": "north-ridge"' in stdout.getvalue()
assert stderr.getvalue() == ""The compact main() snapshot above treats stdin as its source regardless of the input name; the final lab will connect file selection properly. This check still demonstrates explicit arguments and streams.
Check a real process from another directory
After installing the local project into its virtual environment:
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as directory:
completed = subprocess.run(
["trail-report", "-", "--status", "open"],
input="north-ridge|open|Windy\n",
text=True,
capture_output=True,
check=False,
timeout=10,
cwd=Path(directory),
)
assert completed.returncode == 0
assert '"checkpoint": "north-ridge"' in completed.stdout
assert completed.stderr == ""The child cannot rely on the repository as its current directory. If the installed wrapper name is not portable in your environment, build the command list from the environment’s scripts directory or verify [sys.executable, "-m", "trail_report", ...] first.
Do not turn this one smoke check into the complete testing curriculum. Its role is to prove packaging and process integration before shipping the artifact.
7. Treat command behavior as a public interface
Users and automation may depend on:
- program and option spelling;
- position and meaning of arguments;
- help and usage conventions;
- stdout schema and ordering promises;
- stderr diagnostic prefixes;
- exit-status meanings;
- configuration keys and precedence;
- environment variable names; and
- module and installed entry-point names.
Adding an optional --limit with a sensible default can be compatible. Renaming --status to --state, changing JSON keys, or swapping the meanings of status 1 and 2 is breaking unless a transition is designed.
Not every personal script needs a formal deprecation policy. The moment another person, shell script, scheduled task, or service calls the command, treat observable behavior as an interface. Record intentional changes in release notes, preserve aliases for a stated period when compatibility matters, and never use undocumented output accidents as deliberate guarantees.
Trace a changed requirement through the layers
Suppose a teammate requests --format text for people while keeping JSONL as the automation default. A tangled design edits parsing, selection, and printing inside one loop. The layered design makes the impact reviewable:
| Layer | Necessary change | Behavior that should not change |
|---|---|---|
| parser | add --format with jsonl and text choices |
input and status grammar |
| adapter | pass the selected format to rendering | record selection |
| renderer | choose one of two presentation functions | configuration precedence |
| core | none | parsing, validation, filtering, counts |
| process contract | document both stdout formats | stderr and exit status |
Two focused renderers keep branches out of the domain rule:
def write_text(records, stdout):
for record in records:
print(
f"{record['checkpoint']}: {record['status']} — {record['note']}",
file=stdout,
)
def render_records(records, output_format, stdout):
if output_format == "jsonl":
write_jsonl(records, stdout)
elif output_format == "text":
write_text(records, stdout)
else:
raise ValueError(f"unsupported output format {output_format!r}")Argparse choices normally prevent the final branch for a CLI caller. Keeping the renderer’s own clear contract is still useful because another Python caller can call it directly. The core returns the same selected records in both cases.
Now change the requirement again: text mode should sort by checkpoint, but JSONL must preserve streaming order. Sorting belongs in the text presentation path or in an explicitly named adapter step—not silently inside select_records(). Asking “which other behavior should remain unchanged?” is a practical architecture tool, not merely a testing question.
Make startup order visible
Order also carries policy. Parse command syntax before opening a file, so --help does not fail because yesterday’s input disappeared. Resolve and validate settings before processing the first record, so one invalid environment value cannot produce a partial result. Configure logging before opening external resources if file failures should be recorded. Open the input only after every earlier boundary is ready, and close only resources the application opened.
A clear main() reads like that checklist. Clever nesting that opens a file while constructing parser defaults may save one line while making help, cleanup, and partial-output behavior much harder to defend.
8. Choose a tool for the problem it solves
The standard library is enough for this unit. Other tools can improve a real product after requirements justify them:
| Tool | Principal value | Good fit | What it does not fix |
|---|---|---|---|
argparse |
dependency-free parsing, help, validation, subcommands | standard-library tools and explicit parser design | tangled core, stream contracts, settings policy |
| Click | composable commands, nesting, decorators, established ecosystem | larger command groups and reusable command components | domain architecture or output discipline by itself |
| Typer | type-hint-driven command declarations, help, completion | teams that prefer typed function-style command declarations | configuration ownership or secret safety automatically |
| Rich | styled text, tables, panels, progress, terminal rendering | human-facing presentation modes | parsing, reusable core, machine-readable stdout |
A team may combine a parser framework with Rich for a human display and still offer a plain JSON mode. The architecture remains the same: process adapters on the outside, ordinary core values inside.
Evaluate a dependency with concrete questions:
- Which current requirement becomes simpler or safer?
- Does the team understand its public interface and release compatibility?
- What installation, startup, maintenance, and update cost does it add?
- Can core logic remain independent of framework-specific objects?
- Is the output still accessible and usable without terminal styling?
Do not rewrite a working argparse command merely because a framework is popular. Do not reject a framework when it clearly reduces repeated complexity. Choose from evidence.
Checkpoint: choose a CLI tool without outsourcing architecture
9. Ship the complete trail-report package
Assemble the lesson pieces into the package tree shown earlier. Preserve these contracts:
build_parser()acceptsINPUT, configurable status and limit, strict mode, and verbosity;resolve_settings()applies default < TOML < environment < CLI;open_input()maps-to supplied stdin and owns only files it opens;- core record parsing and selection import no argparse, environment, logging configuration, or process-exit functions;
- a JSONL renderer writes only data to supplied stdout;
- concise anticipated failures go to supplied stderr;
- logging is configured once on stderr and does not change JSONL;
main()accepts explicit argv, streams, and environment and returns 0 or 1;- argparse retains status 2 for grammar errors;
__main__.pyand the installed project script call the samemain(); and- one subprocess proof runs from outside the repository.
Build evidence in this order:
- direct core assertions;
- parser help and invalid-value capture;
- settings precedence cases;
main()withStringIOand an injected environment;- module execution in the project environment;
- wheel or editable installation into a clean environment; and
- installed command from an unrelated current directory.
Hint 1: draw imports before writing glue
records.py and render.py should not import cli.py. cli.py may import the core, configuration, and renderer. __main__.py imports only main. If arrows point from core modules back to the command adapter, reconsider the contract.
Hint 2: make main a translation checklist
Insidemain(): resolve default collaborators, parse arguments, load/resolve settings, configure logging, acquire input, call core, render, translate only anticipated failures, and return status. Keep each step visible rather than nested inside one giant expression.
Hint 3: prove installation rather than source-directory luck
Create a temporary directory and use it ascwd for subprocess.run(). Pass stdin text, capture both outputs, disable automatic raising for nonzero status, set a timeout, and assert exact public behavior.
Show a compact complete coordinator after attempting the lab
import argparse
import io
import json
import os
import sys
from contextlib import nullcontext
from pathlib import Path
def build_complete_parser():
parser = argparse.ArgumentParser(prog="trail-report")
parser.add_argument("input", metavar="INPUT")
parser.add_argument(
"--status", choices=("open", "limited", "closed"), default=None
)
parser.add_argument("--limit", type=int, default=None)
parser.add_argument("--strict", action="store_true", default=None)
return parser
def input_context(input_name, stdin):
if input_name == "-":
return nullcontext(stdin)
return Path(input_name).open(encoding="utf-8")
def complete_main(argv=None, stdin=None, stdout=None, stderr=None, environment=None):
stdin = sys.stdin if stdin is None else stdin
stdout = sys.stdout if stdout is None else stdout
stderr = sys.stderr if stderr is None else stderr
environment = os.environ if environment is None else environment
arguments = build_complete_parser().parse_args(argv)
status_name = arguments.status
if status_name is None:
status_name = environment.get("TRAIL_REPORT_STATUS", "closed")
limit = 20 if arguments.limit is None else arguments.limit
if limit < 1:
print("trail-report: limit must be at least 1", file=stderr)
return 1
try:
with input_context(arguments.input, stdin) as lines:
parsed = [parse_record(line) for line in lines if line.strip()]
selected = select_status(parsed, status_name, limit)
except (OSError, ValueError) as error:
print(f"trail-report: {error}", file=stderr)
return 1
for record in selected:
print(json.dumps(record, ensure_ascii=False, sort_keys=True), file=stdout)
return 0
sample_in = io.StringIO("north-ridge|open|Windy\n")
sample_out = io.StringIO()
sample_err = io.StringIO()
sample_status = complete_main(
["-", "--status", "open"],
stdin=sample_in,
stdout=sample_out,
stderr=sample_err,
environment={},
)
assert sample_status == 0
assert '"north-ridge"' in sample_out.getvalue()
assert sample_err.getvalue() == ""cli.py.
Key points
- Untangle a CLI by separating command grammar, configuration, resources, core behavior, rendering, diagnostics, and process termination.
- Keep argparse namespaces and global process resources at the adapter boundary; pass ordinary parameters or validated dataclasses into reusable code.
- Connect module execution and installed project scripts to one callable
main()implementation. - Check pure rules directly, check orchestration with injected collaborators, and check packaging and process behavior with a real subprocess from another directory.
- Treat argument spelling, help, output formats, diagnostics, configuration names, and exit statuses as public interfaces once other people or programs depend on them.
- Click, Typer, and Rich can improve specific experiences, but no framework automatically repairs tangled responsibilities or polluted streams.