Architecture

Following POSIX and GNU Argument Conventions in Python

Make a Python CLI behave the way command-line users expect: short and long options, --opt=value, --, the - convention, -h, exit status 2 and stream rules.

Updated

Command-line users carry decades of expectations into every new tool. -h shows help. -v -v or -vv means more verbose. --output=file.txt and --output file.txt mean the same thing. -- stops option parsing so a filename starting with a dash is treated as a filename. - means standard input. A usage error exits with status 2. None of this is enforced by the operating system; it is convention — partly written down in the POSIX utility syntax guidelines, extended by the GNU coding standards, and reinforced by every tool people use daily. A CLI that follows these conventions feels immediately familiar; one that breaks them feels foreign and is harder to script. This guide lists the conventions that matter, shows which ones Click, Typer and argparse give you for free, and implements the ones they leave to you. It belongs to the designing CLI interfaces and conventions topic.

Prerequisites

  • A CLI built with Click, Typer or argparse.
  • An interest in making it behave well in pipelines and scripts, not only interactively.

The conventions, and who implements them

Argument conventions users already know POSIX and GNU command line conventions, examples of each, and whether Click and argparse follow them by default. Argument conventions users already know Convention Example Click / argparse Short flags combine -xvf file yes / yes Long options (GNU) --verbose yes / yes --opt=value --output=out.json yes / yes -- ends options rm -- -weird-file yes / yes - means stdin/stdout cat - file your code / your code Options after args ls dir -l yes / yes The frameworks give you most conventions for free; the dash convention is yours to implement.

Short options are a single dash and a single letter (-v), and several without arguments can be combined (-xvf file). Long options (a GNU extension, now universal) are two dashes and a word (--verbose). An option's value can follow as the next word or be attached: -o out.txt, -oout.txt, --output out.txt, --output=out.txt. Options and positional arguments may be interleaved (ls dir -l), another GNU extension that POSIX itself does not require. -- ends option processing: everything after it is positional, even if it starts with a dash. And - as a filename means standard input (or output), by long convention.

Click and argparse implement all of these except the last: - is just a string until your code gives it meaning. Typer, which is built on Click, behaves the same way.

The recipe: the parts you implement

Here is a small Typer command that follows the conventions end to end — combined short flags, -h, reading - as stdin, writing results to stdout and everything else to stderr, and exit status 2 for usage errors:

# src/mytool/cli.py
from typing import Annotated

import click
import typer

app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})


@app.command()
def count(
    files: Annotated[list[typer.FileText], typer.Argument(help="Files to read; '-' for stdin.")] = None,
    lines: Annotated[bool, typer.Option("--lines", "-l", help="Count lines.")] = False,
    words: Annotated[bool, typer.Option("--words", "-w", help="Count words.")] = False,
    verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
) -> None:
    """Count lines and words, like a small wc."""
    if not (lines or words):
        lines = words = True
    sources = files or [click.open_file("-")]        # no files: read stdin, like wc
    total_l = total_w = 0
    for fh in sources:
        text = fh.read()
        n_l, n_w = text.count("\n"), len(text.split())
        total_l, total_w = total_l + n_l, total_w + n_w
        name = "-" if fh.name == "<stdin>" else fh.name
        cols = ([f"{n_l:>7}"] if lines else []) + ([f"{n_w:>7}"] if words else [])
        typer.echo(" ".join(cols + [name]))            # results -> stdout
        if verbose:
            typer.echo(f"read {name}", err=True)        # narration -> stderr
    if len(sources) > 1:
        cols = ([f"{total_l:>7}"] if lines else []) + ([f"{total_w:>7}"] if words else [])
        typer.echo(" ".join(cols + ["total"]))


if __name__ == "__main__":
    app()

What this gives users, with only one explicit line of convention handling:

mytool -lw notes.txt todo.txt          # combined short flags
mytool --lines=true ...                # rejected: boolean flags take no value, as users expect
cat notes.txt | mytool -l              # no files: read stdin
mytool -l - < notes.txt                # '-' is stdin, via typer.FileText / click.File
mytool -l -- -draft.md                 # '--' ends options; '-draft.md' is a file
mytool -h                              # short help, because help_option_names includes -h
mytool --bogus                         # usage error, exit status 2

typer.FileText (Click's click.File) is what makes - work: Click opens the special name - as stdin for read mode and stdout for write mode, and opens real paths lazily with proper error messages. Reading piped input more generally is covered in reading piped input in Python CLIs.

-- matters most in scripts

Why -- matters Terminal output showing a filename beginning with a dash being mistaken for an option, and the double dash separator fixing it. Why -- matters bash $ mytool lint -draft.md Error: No such option: -d $ mytool lint -- -draft.md -draft.md: ok Scripts that pass arbitrary filenames should always put -- before them.

Filenames can begin with a dash, and scripts that pass arbitrary filenames to your tool must be able to say "these are files, not options". Click and argparse support -- automatically; your documentation should mention it, and any code of yours that builds command lines for other programs should use it too — the same protection described in avoiding shell injection in Python CLIs.

argparse settings worth changing

argparse follows the conventions too, with two defaults worth revisiting:

import argparse

parser = argparse.ArgumentParser(
    prog="mytool",
    allow_abbrev=False,          # --verb must not silently mean --verbose
)
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument("files", nargs="*", type=argparse.FileType("r"), default=["-"])

allow_abbrev=True (the default) accepts any unambiguous prefix of a long option. It is convenient until you add --verify, at which point every script using --ver breaks. argparse.FileType("r") gives the same - handling as click.File. More argparse specifics are in the argparse topic.

Exit statuses are conventions too

Conventional exit statuses Exit status conventions a command line tool should follow: zero for success, one for general failure, two for usage errors, and higher codes for specific conditions. Conventional exit statuses 0 ok success — including "nothing to do" 1 failure the operation failed, or a check found problems 2 usage usage error: bad flags or arguments (Click and argparse default) 64–78 / 124–127 / 128+n specific sysexits, timeout, not found, killed by signal Exit statuses are part of the conventions users script against, just like flag syntax.

0 means success, any non-zero value means failure, and several values have established meanings: 1 for a general failure, 2 for incorrect usage (which Click and argparse already use for parse errors), 126 and 127 for "cannot execute" and "not found" from shells, 124 from timeout(1), 128 plus the signal number for processes killed by a signal, and the sysexits.h range 64–78 for specific conditions. Scripts depend on these; the full treatment is in choosing exit codes for CLI tools.

UX considerations

  • stdout for results, stderr for everything else. The single most important stream convention: progress, warnings, prompts and diagnostics go to stderr so pipelines carry only data. See working with stdin, stdout and pipes.
  • Respect environment conventions. NO_COLOR disables colour, TERM=dumb implies no fancy output, PAGER chooses the pager, EDITOR the editor, TMPDIR the temporary directory. Rich and Click already honour several of these.
  • Silence is golden. Unix tools say nothing on success unless they produce data. A tool that prints "Done!" after every command is noisy in scripts; reserve such messages for -v.
  • Accept -h. Click's default is only --help; adding -h through help_option_names matches what users try first — unless -h means something else in your domain (such as "host"), in which case do not overload it.
  • Do not invent syntax. Custom forms like +option, /option or option:value are learnable but unexpected; prefer the standard forms even when a custom one would be shorter.

Testing the behaviour

Conventions are exactly the kind of behaviour that regresses quietly, so pin them with tests:

# tests/test_conventions.py
from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner()


def test_combined_short_flags(tmp_path):
    f = tmp_path / "a.txt"
    f.write_text("one two\nthree\n")
    result = runner.invoke(app, ["-lw", str(f)])
    assert result.exit_code == 0
    assert result.stdout.split()[:2] == ["2", "3"]


def test_dash_reads_stdin():
    result = runner.invoke(app, ["-l", "-"], input="a\nb\nc\n")
    assert result.stdout.split()[0] == "3"


def test_no_files_reads_stdin():
    result = runner.invoke(app, ["-w"], input="x y z\n")
    assert result.stdout.split()[0] == "3"


def test_double_dash_allows_dash_filenames(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    (tmp_path / "-draft.md").write_text("hi\n")
    assert runner.invoke(app, ["-l", "--", "-draft.md"]).exit_code == 0


def test_short_help_and_usage_errors():
    assert runner.invoke(app, ["-h"]).exit_code == 0
    assert runner.invoke(app, ["--bogus"]).exit_code == 2


def test_narration_goes_to_stderr(tmp_path):
    f = tmp_path / "a.txt"
    f.write_text("x\n")
    result = runner.invoke(app, ["-v", str(f)])
    assert "read" not in result.stdout and "read" in result.stderr

The last test relies on Click 8.2+ CliRunner, which captures stdout and stderr separately (result.stdout, result.stderr). It is the test that keeps diagnostics out of pipelines. For whole-output comparisons, see snapshot testing CLI output.

Conclusion

POSIX and GNU conventions are the shared grammar of the command line, and following them is mostly free: Click, Typer and argparse already handle short and long options, combined flags, --opt=value, interleaving and --. Your part is giving - its meaning with click.File or argparse.FileType, adding -h where appropriate, disabling argparse's abbreviations, keeping stdout for results and stderr for everything else, honouring the usual environment variables and using conventional exit statuses. Pin them with tests, and your tool will behave the way users' fingers already expect.

Frequently asked questions

Should options be allowed after positional arguments?

Yes — GNU behaviour, which Click and argparse follow, and what users expect (mytool file.txt -v). POSIX strictly stops option processing at the first positional argument; set POSIXLY_CORRECT-style behaviour only if you have a specific need, such as a command that passes its trailing arguments to another program.

How do I pass trailing arguments through to another program?

Use context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True} on that command and an argument with nargs=-1, so mytool run -- pytest -x -q hands pytest -x -q through untouched. uv run and poetry run work this way.

Should an option be allowed more than once?

Only when repetition has an obvious meaning. Counting flags (-vvv) and accumulating options (--tag a --tag b) are conventional; for everything else, the last occurrence should win, which is what Click and argparse do by default. That rule is what lets a script set a default early in a command line and a caller override it by appending the same option later, a pattern shell wrappers rely on.

Is -v for version acceptable?

It conflicts with the far more common -v for verbose. Use --version (and -V if you want a short form, as Python itself does).

What about Windows-style /option syntax?

Python CLI frameworks do not support it, and modern Windows tools (git, docker, winget) use dash syntax. Stick with dashes everywhere; Windows users are already used to them.