Alternatives¶
This page compares without-cli against the three parsers a Python program
usually reaches for: argparse
in the standard library, click, and
typer. It is built around one program written
four times, because a feature list hides the thing that actually decides the
experience: how much you type per parameter, what the type checker catches
before you run it, and where a shared resource can live.
Measurements below are against Python 3.14, click 8.4, and typer 0.27. Every
spelling is the shortest equivalent one, so a shorter spelling that also
changes behaviour (click's --debug/--no-debug, which adds a negation the
others do not have) is not used to win a row.
The structural difference to keep in mind while reading is what each library
treats as the declaration. argparse builds a mutable parser by calling methods
on it. click constructs parameter objects but attaches them to a function
through decorators and registration. typer derives everything by introspecting
a function signature, so an annotation is the only channel and it does five
jobs at once. Here a token is an ordinary value: option(("-t", "--tag"),
many(STR)) is a thing you can name, share, and pass to command, and the
overload ladder ties it to the handler parameter it fills.
The same program, four ways¶
A todos CLI with a counted -v, an --endpoint that falls back to the
environment, an add taking a positional and a repeatable -t/--tag, an ls
taking an int option, and a nested db group whose --dsn reaches only the
commands under it. All four talk to the same Client, whose connect is an
async context manager, because that is what a real CLI holds.
without-cli¶
verbose = count(("-v", "--verbose"), summary="Raise log level; repeat for more.")
endpoint = option(
"--endpoint",
default("http://localhost:8000", STR),
sources=(FromEnv("TODOS_ENDPOINT"),),
summary="Todos service base URL.",
)
@dataclass(frozen=True, slots=True)
class Session(Streams):
client: Client
verbosity: int
@dataclass(frozen=True, slots=True)
class Database:
session: Session
dsn: str
@command("add", argument("text", once(STR)), option(("-t", "--tag"), many(STR)), summary="Add a todo.")
async def add(session: Session, text: str, tags: tuple[str, ...]) -> int:
session.stdout.write(f"{await session.client.create(text, tags)}\n")
return 0
@command("ls", option("--limit", default(10, INT)), summary="List todos.")
async def ls(session: Session, limit: int) -> int:
for todo in await session.client.list(limit):
session.stdout.write(f"{todo}\n")
return 0
@command("migrate", summary="Apply migrations.")
async def migrate(db: Database) -> int:
db.session.stdout.write(f"migrated {db.dsn}\n")
return 0
@asynccontextmanager
async def session(streams: Streams, verbosity: int, base: str) -> AsyncIterator[Session]:
async with Client.connect(base) as client:
yield Session(
stdin=streams.stdin,
stdout=streams.stdout,
stderr=streams.stderr,
client=client,
verbosity=verbosity,
)
@asynccontextmanager
async def database(session: Session, dsn: str) -> AsyncIterator[Database]:
yield Database(session=session, dsn=dsn)
app = group(
"todos",
verbose,
endpoint,
state=session,
commands=(
add,
ls,
group("db", option("--dsn", once(STR)), state=database, commands=(migrate,)),
),
)
if __name__ == "__main__":
raise SystemExit(run(app))
click¶
@click.group()
@click.option("-v", "--verbose", count=True, help="Raise log level; repeat for more.")
@click.option(
"--endpoint",
default="http://localhost:8000",
envvar="TODOS_ENDPOINT",
show_envvar=True,
help="Todos service base URL.",
)
@click.pass_context
def todos(ctx: click.Context, verbose: int, endpoint: str) -> None:
# The client cannot be opened here: `connect` is an async context manager and
# the callback is sync, so entering it would need a loop that outlives this
# function. Each leaf opens its own instead.
ctx.obj = {"verbosity": verbose, "endpoint": endpoint}
@todos.command(help="Add a todo.")
@click.argument("text")
@click.option("-t", "--tag", "tags", multiple=True)
@click.pass_obj
def add(obj: dict[str, object], text: str, tags: tuple[str, ...]) -> None:
async def go() -> None:
base = obj["endpoint"]
assert isinstance(base, str)
async with Client.connect(base) as client:
click.echo(await client.create(text, tags))
asyncio.run(go())
@todos.command(help="List todos.")
@click.option("--limit", default=10)
@click.pass_obj
def ls(obj: dict[str, object], limit: int) -> None:
async def go() -> None:
base = obj["endpoint"]
assert isinstance(base, str)
async with Client.connect(base) as client:
for todo in await client.list(limit):
click.echo(todo)
asyncio.run(go())
@todos.group()
@click.option("--dsn", required=True)
@click.pass_obj
def db(obj: dict[str, object], dsn: str) -> None:
obj["dsn"] = dsn
@db.command(help="Apply migrations.")
@click.pass_obj
def migrate(obj: dict[str, object]) -> None:
click.echo(f"migrated {obj['dsn']}")
if __name__ == "__main__":
todos()
typer¶
app = typer.Typer()
db_app = typer.Typer()
app.add_typer(db_app, name="db")
@app.callback()
def todos(
ctx: typer.Context,
verbose: Annotated[int, typer.Option("-v", "--verbose", count=True, help="Raise log level; repeat for more.")] = 0,
endpoint: Annotated[
str, typer.Option(envvar="TODOS_ENDPOINT", show_envvar=True, help="Todos service base URL.")
] = "http://localhost:8000",
) -> None:
ctx.obj = {"verbosity": verbose, "endpoint": endpoint}
@app.command(help="Add a todo.")
def add(
ctx: typer.Context,
text: str,
tag: Annotated[list[str] | None, typer.Option("-t", "--tag")] = None,
) -> None:
async def go() -> None:
async with Client.connect(ctx.obj["endpoint"]) as client:
typer.echo(await client.create(text, tuple(tag or ())))
asyncio.run(go())
@app.command(help="List todos.")
def ls(ctx: typer.Context, limit: int = 10) -> None:
async def go() -> None:
async with Client.connect(ctx.obj["endpoint"]) as client:
for todo in await client.list(limit):
typer.echo(todo)
asyncio.run(go())
@db_app.callback()
def db(ctx: typer.Context, dsn: Annotated[str, typer.Option()]) -> None:
ctx.obj["dsn"] = dsn
@db_app.command(help="Apply migrations.")
def migrate(ctx: typer.Context) -> None:
typer.echo(f"migrated {ctx.obj['dsn']}")
if __name__ == "__main__":
app()
argparse¶
def build() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="todos")
parser.add_argument("-v", "--verbose", action="count", default=0, help="Raise log level; repeat for more.")
parser.add_argument(
"--endpoint",
default=os.environ.get("TODOS_ENDPOINT", "http://localhost:8000"),
help="Todos service base URL. [env: TODOS_ENDPOINT]",
)
subs = parser.add_subparsers(dest="command", required=True)
add = subs.add_parser("add", help="Add a todo.")
add.add_argument("text")
add.add_argument("-t", "--tag", dest="tags", action="append", default=[])
ls = subs.add_parser("ls", help="List todos.")
ls.add_argument("--limit", type=int, default=10)
db = subs.add_parser("db")
db.add_argument("--dsn", required=True)
db_subs = db.add_subparsers(dest="db_command", required=True)
db_subs.add_parser("migrate", help="Apply migrations.")
return parser
async def dispatch(args: argparse.Namespace, out: TextIO) -> int:
if args.command == "db":
out.write(f"migrated {args.dsn}\n")
return 0
async with Client.connect(args.endpoint) as client:
if args.command == "add":
out.write(f"{await client.create(args.text, tuple(args.tags))}\n")
return 0
for todo in await client.list(args.limit):
out.write(f"{todo}\n")
return 0
def main(argv: Sequence[str] | None = None) -> int:
return asyncio.run(dispatch(build().parse_args(argv), sys.stdout))
The without-cli version is the longest file of the four, and two thirds of
what it spends the extra lines on are the Session and Database dataclasses.
That is the trade the rest of this page is about: those two types are what the
other three replace with an untyped dict and, in click's and typer's case, a
resource opened once per leaf instead of once per invocation.
argparse's dispatch is where its own cost lands. Every field on Namespace is
untyped, the subcommand arrives as a string to branch on, and the branch and the
declaration that produced it sit in different functions with nothing tying them
together.
What one parameter costs¶
The shortest equivalent spelling of the same seven parameter kinds:
| without-cli | click | typer | argparse | |
|---|---|---|---|---|
required str positional |
argument("text", once(STR)) |
@click.argument("text") |
text: str |
p.add_argument("text") |
int option with a default |
option("--limit", default(10, INT)) |
@click.option("--limit", default=10) |
limit: int = 10 |
p.add_argument("--limit", type=int, default=10) |
| boolean flag | flag("--debug") |
@click.option("--debug", is_flag=True) |
debug: bool = False |
p.add_argument("--debug", action="store_true") |
| repeatable option, short and long | option(("-t", "--tag"), many(STR)) |
@click.option("-t", "--tag", "tags", multiple=True) |
tag: Annotated[list[str] \| None, typer.Option("-t", "--tag")] = None |
p.add_argument("-t", "--tag", dest="tags", action="append", default=[]) |
| counted flag, short and long | count(("-v", "--verbose")) |
@click.option("-v", "--verbose", count=True) |
verbose: Annotated[int, typer.Option("-v", "--verbose", count=True)] = 0 |
p.add_argument("-v", "--verbose", action="count", default=0) |
| option with an environment fallback | option("--endpoint", default("http://x", STR), sources=(FromEnv("E"),)) |
@click.option("--endpoint", default="http://x", envvar="E", show_envvar=True) |
endpoint: Annotated[str, typer.Option(envvar="E", show_envvar=True)] = "http://x" |
p.add_argument("--endpoint", default=os.environ.get("E", "http://x")) |
| option with a secret-file fallback | option("--token", once(STR), sources=(FromFile(Path("/run/secrets/t")),)) |
read the file yourself | read the file yourself | read the file yourself |
Characters typed, over the six kinds all four can express:
| without-cli | click | typer | argparse |
|---|---|---|---|
| 208 | 269 | 264 | 315 |
typer is the shortest thing here and also the longest, which is the whole story
of deriving a parser from a signature. While the annotation carries no
configuration, limit: int = 10 cannot be beaten. The moment a parameter needs
a short alias, a count, or repeatability, the configuration has nowhere to go
but Annotated, and the same two rows that without-cli writes in 34 and 26
characters cost typer 68 and 72. Rows one and two are where typer wins; rows
four and five are where it comes last.
Three rows are worth reading against their behaviour rather than their length.
The first is the price of one vocabulary: spelling a required positional
once(STR) rather than STR costs six characters and is what makes
optional(STR), default("x", STR), and many(STR) available on a positional
without a second function for each. Both click and typer get a --no-debug
negation from their boolean spelling, which flag does not have. And the last
row has no competition at all: reading a projected Kubernetes secret is a
sources= entry here and hand-written file handling everywhere else, validated
by a different code path from the one that validates the command line.
What the type checker catches¶
Three mistakes, each a mismatch between what a command declares and what its handler reads:
# A: the token parses a str, the handler declares an int.
@command("add", argument("text", once(STR)), option(("-t", "--tag"), many(STR)))
async def add(streams: Streams, text: int, tags: tuple[str, ...]) -> int: ...
# B: the tokens are declared in the other order from the handler's parameters.
@command("swap", argument("count", once(INT)), argument("name", once(STR)))
async def swap(streams: Streams, name: str, count: int) -> int: ...
# C: a handler parameter with no token behind it.
@command("extra", argument("text", once(STR)))
async def extra(streams: Streams, text: str, missing: str) -> int: ...
mypy --strict reports all three, with no runtime introspection anywhere:
probe.py:2: error: Argument 1 has incompatible type "Callable[[Streams, int, tuple[str, ...]], Coroutine[Any, Any, int]]"; expected "Callable[[Streams, str, tuple[str, ...]], Awaitable[int]]" [arg-type]
probe.py:7: error: Argument 1 has incompatible type "Callable[[Streams, str, int], Coroutine[Any, Any, int]]"; expected "Callable[[Streams, int, str], Awaitable[int]]" [arg-type]
probe.py:12: error: Argument 1 has incompatible type "Callable[[Streams, str, str], Coroutine[Any, Any, int]]"; expected "Callable[[Streams, str], Awaitable[int]]" [arg-type]
The same mistakes written against click, typer, and argparse produce no errors under the same checker. click's parameter declarations and its handler's annotations are two independent statements, so an annotation there is a claim rather than a check, and both survivable mistakes land at runtime:
add -> TypeError('can only concatenate str (not "int") to str')
extra -> TypeError("extra() missing 1 required positional argument: 'missing'")
typer cannot express mistakes A or B, because there is only one declaration for the checker and the parser to disagree about. That is the real strength of deriving from a signature, and it is why the interesting typer comparison is not this one but the next.
argparse types nothing: Namespace attributes are dynamic, so args.text + 1
and args.missing both pass the checker and fail at runtime.
Where a resource lives¶
This is the difference that survives every other row. A CLI usually has one client, pool, or connection that every command under a group needs, built from that group's own options.
Here that is the group's state: an async context manager taking its parent's
state and this level's parsed options, entered only when something beneath it is
selected. todos db --dsn ... migrate opens the database and todos ls never
touches it, the state arrives at the handler typed, and the chain Streams ->
Session -> Database is checked at assembly, so a command wanting a Session
cannot be placed under a group that builds something else.
Neither click nor typer has an equivalent, for the same mechanical reason:
the group callback is synchronous. click can hold a resource open for the
duration of a command with Context.with_resource, but its signature takes an
AbstractContextManager, so an async one needs a loop outliving the callback,
which means driving an AsyncExitStack by hand and closing it from
Context.call_on_close. Both versions above take the other way out, which is
what a real program does too: each leaf opens its own client and runs its own
asyncio.run. The invocation-scoped resource becomes a command-scoped one, and
what the callback can pass down goes through ctx.obj, an untyped mutable
mapping that a parent writes and a child fishes out.
argparse has no callback at all, so the resource and the dispatch both end up in
whatever function the author writes after parse_args.
The register¶
| without-cli | click | typer | argparse | |
|---|---|---|---|---|
| Parser is a value, no registration | yes | parameters are values, groups register | no | no |
| Parse without exiting the process | parse_argv returns an outcome |
main(standalone_mode=False) |
via typer.main.get_command |
parse_args exits on error |
| Which flags are magic | the shell's list, not the parser's | fixed in the library | fixed in the library | fixed in the library |
| Handler and declaration checked against each other | yes, statically | no | not applicable (one declaration) | no |
| Typed state built per group | yes | ctx.obj, untyped, sync only |
ctx.obj, untyped, sync only |
no |
async commands |
always | no; run a loop yourself | no; run a loop yourself | no |
| Streams injected rather than global | yes | CliRunner patches them for tests |
same as click | no |
| Environment variable fallback | FromEnv |
envvar= |
envvar= |
hand-written |
| File (secret mount) fallback | FromFile |
no | no | no |
| Help text from the handler's docstring | yes, when summary= is omitted |
yes | yes | no |
| Choice or enum values | choice over an Enum |
click.Choice |
Enum annotation |
choices= |
| Bounded numbers, path existence checks | no; write a Converter |
IntRange, click.Path |
same as click | type= callable |
| Converter's own placeholder shown in usage | no; the option's name is used | yes | yes | metavar= |
| Shell completion | no | yes, per-shell eval | yes, plus --install-completion |
no |
--version |
version= on a level, read by the shell |
version_option |
--version callback |
action="version" |
--no- negation for flags |
no, by position | --x/--no-x |
from a bool annotation |
BooleanOptionalAction |
| Optional positional | argument(name, optional(...)) |
required=False |
default value | nargs="?" |
| Prompts, progress bars, colour | no, by position | yes | yes, via rich | no |
| Runtime dependencies | none | none | click, rich, shellingham | none |
One row in that table is a without-cli limit rather than a position taken
deliberately. A Converter carries the metavar naming what it parses, and
choice(Profile) puts [dev|prod] there. That reaches the rejection message but
not the help, because an option's usage placeholder is derived from the option's
own name:
One declaration, two consumers, and they disagree, which is exactly what
tokens exist to prevent.
Both click and typer show the converter instead (--limit INTEGER,
--profile [dev|prod]); showing the option's own name is the argparse
convention.
Everything else in that table's lower half is in what is deliberately absent, where the reasoning for each lives.
What each one is better at¶
- argparse is in the standard library. That is the whole argument and it is often decisive. Nothing here or in click or typer is worth a dependency for a script with two flags.
- click is the mature one. Its parameter types, its testing runner, its
completion, and its ecosystem are all things this package either lacks or has
a thinner version of, and it takes no runtime dependency to provide them. What
it entangles is the context:
Context.objis an ambient mutable place,ParamType.convert(value, param, ctx)takes the context so a converter is not a pure function, and registration mutates a group. - typer is the shortest way to put a command line on a function that already
exists, and its completion story is the best of the four. Its ceiling is that
the annotation is the only channel: what the annotation cannot say, you cannot
say, and its escape hatch,
typer.main.get_command(app), drops you into click's model rather than a lower rung of its own. - without-cli is the one to reach for when commands share a typed resource, when values arrive from mounted files as well as the environment, or when you want the parser to be a value you can test, ship, and place in someone else's tree. It costs a longer file and a smaller feature set.