Command-line interface
- Lesson 17
- 45 min
- Module 6: From notebook to maintainable project
Lesson 17 of 18 · Python for Practical AI EngineeringCommand-line interface
1. Computer and environment basics
2. Values and control flow
3. Functions and program structure
4. Files, data and external services
5. Debugging and reliability
6. From notebook to maintainable project
What you will be able to do
Run ticket-cleaner as one command, with the input file and the options on the command line: python -m ticket_cleaner data/tickets.csv --status pending. A user does not need to read the code or set variables to use it, and --help explains every option.
NotebookDo this lesson in a notebook
The notebook has every step, task and check of this lesson, with the instructions. Its first cells make the ticket-cleaner folder as at the start of this lesson, with the tests. They also install httpx and pytest and download the data files. So the notebook works without your local project. Read the explanations here, and run the code there.
Colab and Kaggle open your own copy. They need a free Google or Kaggle account. Stuck? See the solution notebook.
Before you startWhat to know and to have ready
What you need to know
- Arguments, options and exit codes, from Files and terminal.
- Catching errors at the edge, in
main(), from Exceptions and logging. python -mand__name__, from Modules and imports, andtmp_path, from Focused tests.
What you need on your computer (only for the local route; the notebook needs none of it)
count_tickets.pythat reads its source fromTICKETS_SOURCE, logs, and returns exit codes. You made it in Exceptions and logging: Guided practice. If it is different, do that section again. You can also copy the files from the setup cell of this lesson’s notebook. It has every file as at the start of this lesson.- The tests in
tests/, which pass withpython -m pytest, from Focused tests: Guided practice and Your turn. pytest is in the environment, from Install pytest. TICKETS_SOURCEis not set in your terminal. Remove it withunset TICKETS_SOURCE(macOS and Linux) orRemove-Item Env:TICKETS_SOURCE(PowerShell), as in Configuration and secrets: Environment variables.data/tickets.csvanddata/tickets.json. You downloaded them in Reading and writing files: Get the data file. If one is missing or changed, download it again with the samecurlcommand. The checksums are in the dataset card.- A terminal in
~/projects/ticket-cleaner. You made the folder in Files and terminal: Guided practice. - The project’s environment
.venv, active in your terminal (the prompt starts with(.venv)). You made it in Isolate dependencies: Guided practice. If it is missing or broken, delete it and make it again from the lock file, as in Make the environment again from nothing.
Tested with Python 3.14.6 on macOS. The notebook was tested in Jupyter.
Arguments: what changes on each run
Section titled “Arguments: what changes on each run”In Files and terminal, commands took arguments (cd projects) and options (python3 --version). Your own program can take them too. Python puts the words of the command into a list, sys.argv:
import sys
print(sys.argv)python show_args.py data/tickets.csv --status pending['show_args.py', 'data/tickets.csv', '--status', 'pending']The first item is the script, and the rest are the words that you typed, as strings. You could read this list yourself, but then you must also handle a missing value, a typing mistake in an option, and the help text. The argparse module does all of that.
Which settings become arguments, and which stay in environment variables? Ask: does it change on each run, or does it belong to the place where the program runs?
| Setting | Changes | So it is |
|---|---|---|
| The input file | On each run | An argument: data/tickets.csv |
| The status to count | On each run | An option: --status pending |
| The report file | Sometimes | An option with a default: --output |
| The service URL, the token, the log level | With the computer or the server | Environment variables, as before |
Worked example: argparse
Section titled “Worked example: argparse”Amira writes a parser that describes the command:
import argparsefrom pathlib import Path
parser = argparse.ArgumentParser( prog="ticket_cleaner", description="Check support-ticket records and write a summary report.",)parser.add_argument( "input", help="a .csv or .json file of tickets, or 'remote' to download them")parser.add_argument( "--status", default="open", choices=sorted(STATUSES), help="which tickets to count (default: open)",)parser.add_argument( "--output", type=Path, default=Path("reports/summary.json"), help="where to save the report (default: reports/summary.json)",)args = parser.parse_args()| Part | Meaning |
|---|---|
"input" |
A positional argument: a name without --. It is required, and it is identified by its place. |
"--status" |
An option: it starts with --, and it is optional. |
default="open" |
The value when the user does not give the option. |
choices=... |
The only values allowed. Anything else is an error, with a message that lists the choices. |
type=Path |
Converts the text to a Path. Without type, every value is a string. |
help="..." |
The text that --help shows. Say what the value is and its default. |
parser.parse_args() |
Reads sys.argv. It returns an object with one attribute for each argument: args.input, args.status, args.output. |
argparse adds -h and --help for you. Here is the help that it makes from the parser:
usage: ticket_cleaner [-h] [--status {closed,open,pending}] [--output OUTPUT] input
Check support-ticket records and write a summary report.
positional arguments: input a .csv or .json file of tickets, or 'remote' to download them
options: -h, --help show this help message and exit --status {closed,open,pending} which tickets to count (default: open) --output OUTPUT where to save the report (default: reports/summary.json)Exit codes, finished
Section titled “Exit codes, finished”The program now has three exit codes, and each one means something different to the person or program that runs it:
| Exit code | Meaning | Example |
|---|---|---|
0 |
Success. The report is written. Rejected records are a normal result, not a failure. | python -m ticket_cleaner data/tickets.csv |
1 |
The input could not be read. | A missing file, or the service is down. |
2 |
The command is wrong. argparse prints the usage and the error, and exits for you. |
--status Pending, or no input |
usage: ticket_cleaner [-h] [--status {closed,open,pending}] [--output OUTPUT] inputticket_cleaner: error: argument --status: invalid choice: 'Pending' (choose from 'closed', 'open', 'pending')Run a package with python -m
Section titled “Run a package with python -m”A package can be run as a program, if it has a file called __main__.py. Then python -m ticket_cleaner runs that file, from the project folder. This replaces count_tickets.py: the program is now part of the package, and the package is the one thing to install, test and run.
The program’s code goes in a new module, cli.py (CLI means command-line interface). __main__.py only starts it:
from ticket_cleaner.cli import main
raise SystemExit(main())import argparseimport loggingfrom pathlib import Path
import httpx
from ticket_cleaner.config import Settings, load_settingsfrom ticket_cleaner.files import read_rows, write_jsonfrom ticket_cleaner.parsing import split_recordsfrom ticket_cleaner.records import STATUSESfrom ticket_cleaner.remote import fetch_rowsfrom ticket_cleaner.report import build_summary
logger = logging.getLogger("ticket_cleaner")
def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="ticket_cleaner", description="Check support-ticket records and write a summary report.", ) parser.add_argument( "input", help="a .csv or .json file of tickets, or 'remote' to download them" ) parser.add_argument( "--status", default="open", choices=sorted(STATUSES), help="which tickets to count (default: open)", ) parser.add_argument( "--output", type=Path, default=Path("reports/summary.json"), help="where to save the report (default: reports/summary.json)", ) return parser
def load_rows(source: str, settings: Settings) -> list[dict]: """Return raw records from a file, or from the web service for "remote".""" if source == "remote": return fetch_rows(settings.tickets_url, token=settings.api_token) return read_rows(Path(source))
def main(argv: list[str] | None = None) -> int: args = make_parser().parse_args(argv) settings = load_settings() logging.basicConfig( level=settings.log_level, format="%(levelname)s %(name)s: %(message)s" ) logger.info("reading tickets from %s", args.input)
try: rows = load_rows(args.input, settings) except (OSError, ValueError, httpx.HTTPError) as error: logger.error("cannot read the tickets from %s: %s", args.input, error) return 1
tickets, rejected = split_records(rows) for record in rejected: logger.warning("row %d rejected: %s", record.row, "; ".join(record.problems))
summary = build_summary(tickets, rejected, args.status) write_json(summary, args.output) logger.info("wrote %s", args.output) print(f"{len(rows)} rows: {len(tickets)} valid, {len(rejected)} rejected.") print(f"Report: {args.output}") return 0Two details:
main(argv: list[str] | None = None).parse_args(None)readssys.argv, which is the normal case. A test can pass its own list instead:main(["data/tickets.csv", "--status", "pending"]). Then a test runs the whole program without a terminal.config.pylosessource. The input is now an argument, so remove thesourcefield fromSettingsand its line fromload_settings. The URL, the token and the log level stay.
Guided practice: from script to command
Section titled “Guided practice: from script to command”-
Make
ticket_cleaner/cli.pyandticket_cleaner/__main__.pywith the code above. -
In
ticket_cleaner/config.py, remove thesourcefield and thesource=...line. -
Delete
count_tickets.py. Its job is now incli.py. -
Show the help:
Terminal window python -m ticket_cleaner --helpYou see the help text from the worked example. In some terminals, it is in colour.
-
Run it with the defaults:
Terminal window python -m ticket_cleaner data/tickets.csvOutput (last lines) WARNING ticket_cleaner: row 14 rejected: missing category15 rows: 9 valid, 6 rejected.Report: reports/summary.json -
Run it for the pending tickets, into a different file:
Terminal window python -m ticket_cleaner data/tickets.json --status pending --output reports/pending.jsonreports/pending.jsonhas"selected": 2. -
Check the three exit codes. Run each command, then
echo $?(or$LASTEXITCODE):python -m ticket_cleaner data/tickets.csvgives0,python -m ticket_cleaner data/nothing.csvgives1, andpython -m ticket_cleaner data/tickets.csv --status Pendinggives2.
Test the command
Section titled “Test the command”Because main accepts a list, a test can run the whole program. Make tests/test_cli.py:
import json
from ticket_cleaner.cli import main
def test_report_for_pending_tickets(tmp_path): output = tmp_path / "pending.json" exit_code = main( ["data/tickets.csv", "--status", "pending", "--output", str(output)] ) assert exit_code == 0 summary = json.loads(output.read_text(encoding="utf-8")) assert summary["selected"] == 2 assert len(summary["rejected"]) == 6
def test_missing_file_gives_exit_code_1(tmp_path): assert main([str(tmp_path / "missing.csv")]) == 1The report goes to tmp_path, so the test never changes reports/. Run python -m pytest. All tests pass.
Your turn: a –strict option
Section titled “Your turn: a –strict option”The help desk lead wants to use ticket-cleaner in an automatic job, which must stop when the data has problems. Add an option --strict. With it, the program still writes the report, but ends with exit code 3 if any record was rejected. Without it, the behaviour does not change.
Use action="store_true": the option takes no value, and args.strict is True when the user gives it. Update the help, and add a test for each case.
Show one answer
In make_parser:
parser.add_argument( "--strict", action="store_true", help="end with exit code 3 if any record is rejected", )At the end of main, instead of return 0:
if args.strict and rejected: return 3 return 0Tests:
def test_strict_with_rejected_records_gives_exit_code_3(tmp_path): output = tmp_path / "summary.json" assert main(["data/tickets.csv", "--strict", "--output", str(output)]) == 3 assert output.exists()
def test_strict_with_clean_data_gives_exit_code_0(tmp_path): clean = tmp_path / "clean.csv" clean.write_text("id,status,category,priority\nT-1,open,login,1\n", encoding="utf-8") assert main([str(clean), "--strict", "--output", str(tmp_path / "s.json")]) == 0Why 3 and not 1? Exit code 1 already means “could not read the input”. A different code lets the automatic job tell the two cases apart.
Common mistake
No module named ticket_cleaner.__main__
Symptom: python -m ticket_cleaner data/tickets.csv stops with one of these:
No module named ticket_cleaner.__main__; 'ticket_cleaner' is a package and cannot be directly executedNo module named ticket_cleanerCause: the first message means that the package has no __main__.py, or that the file has a different name, for example main.py or __main__.py.txt. The second means that Python cannot find the package: the terminal is not in the project folder, or the environment is not active.
Fix:
- Run
ls ticket_cleaner. The list must contain__main__.py, with two_before and aftermain. - Run
pwd. The terminal must be inticket-cleaner, the folder that containsticket_cleaner. - Check that the prompt starts with
(.venv).
Check your understanding
Why is the input file an argument, but the service token an environment variable?
Show the answer
The input changes on each run, so the user gives it in the command. The token belongs to the place where the program runs, and it is a secret. A command can be saved in the terminal history, in a script or in a log, and then shared. An environment variable is not part of the command.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
An automatic job runs ticket-cleaner every night. Which exit code tells it that the report is ready, and which tells it that the service was down?
Show the answer
0 means that the report is written, even if some records were rejected (they are in the report). 1 means that the input could not be read, for example because the service was down, and there is no new report. 2 means that the command itself is wrong, so the job’s configuration needs a fix.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
argparsedescribes a command: positional arguments, options with defaults and choices, and help text. It handles--helpand wrong commands for you.- Values that change on each run are arguments. Values that belong to the environment, and secrets, are environment variables.
- Exit codes say what happened:
0success,1the input could not be read,2a wrong command. __main__.pymakes a package runnable withpython -m package.main(argv=None)lets a test run the whole program with its own arguments.
Next, you add formatting and linting, document how to set up and run the project, and prove that the README works on a fresh copy.
Next: Quality and handoff