Skip to content

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

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 notebookColab · Kaggle · GitHub

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 readyAbout 45 minutes

What you need to know

What you need on your computer (only for the local route; the notebook needs none of it)

Tested with Python 3.14.6 on macOS. The notebook was tested in Jupyter.

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:

show_args.py
import sys
print(sys.argv)
Terminal
python show_args.py data/tickets.csv --status pending
Output
['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

Amira writes a parser that describes the command:

import argparse
from 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:

Output of python -m ticket_cleaner --help
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)

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
Output of python -m ticket_cleaner data/tickets.csv --status Pending
usage: ticket_cleaner [-h] [--status {closed,open,pending}] [--output OUTPUT]
input
ticket_cleaner: error: argument --status: invalid choice: 'Pending' (choose from 'closed', 'open', 'pending')

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:

ticket_cleaner/__main__.py
from ticket_cleaner.cli import main
raise SystemExit(main())
ticket_cleaner/cli.py
import argparse
import logging
from pathlib import Path
import httpx
from ticket_cleaner.config import Settings, load_settings
from ticket_cleaner.files import read_rows, write_json
from ticket_cleaner.parsing import split_records
from ticket_cleaner.records import STATUSES
from ticket_cleaner.remote import fetch_rows
from 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 0

Two details:

  • main(argv: list[str] | None = None). parse_args(None) reads sys.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.py loses source. The input is now an argument, so remove the source field from Settings and its line from load_settings. The URL, the token and the log level stay.
  1. Make ticket_cleaner/cli.py and ticket_cleaner/__main__.py with the code above.

  2. In ticket_cleaner/config.py, remove the source field and the source=... line.

  3. Delete count_tickets.py. Its job is now in cli.py.

  4. Show the help:

    Terminal window
    python -m ticket_cleaner --help

    You see the help text from the worked example. In some terminals, it is in colour.

  5. Run it with the defaults:

    Terminal window
    python -m ticket_cleaner data/tickets.csv
    Output (last lines)
    WARNING ticket_cleaner: row 14 rejected: missing category
    15 rows: 9 valid, 6 rejected.
    Report: reports/summary.json
  6. Run it for the pending tickets, into a different file:

    Terminal window
    python -m ticket_cleaner data/tickets.json --status pending --output reports/pending.json

    reports/pending.json has "selected": 2.

  7. Check the three exit codes. Run each command, then echo $? (or $LASTEXITCODE): python -m ticket_cleaner data/tickets.csv gives 0, python -m ticket_cleaner data/nothing.csv gives 1, and python -m ticket_cleaner data/tickets.csv --status Pending gives 2.

Because main accepts a list, a test can run the whole program. Make tests/test_cli.py:

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")]) == 1

The report goes to tmp_path, so the test never changes reports/. Run python -m pytest. All tests pass.

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 0

Tests:

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")]) == 0

Why 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 executed
No module named ticket_cleaner

Cause: 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:

  1. Run ls ticket_cleaner. The list must contain __main__.py, with two _ before and after main.
  2. Run pwd. The terminal must be in ticket-cleaner, the folder that contains ticket_cleaner.
  3. 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.

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.

Recap

  • argparse describes a command: positional arguments, options with defaults and choices, and help text. It handles --help and 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: 0 success, 1 the input could not be read, 2 a wrong command.
  • __main__.py makes a package runnable with python -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