argparse, Click and Typer cover the large majority of Python command-line tools, and most of this site uses them. But they are not the only serious options, and the alternatives are not just historical curiosities. Cyclopts takes Typer's type-hint idea and fixes several of its rough edges. Python Fire turns any object into a CLI with one line, which is unbeatable for internal scripts. docopt-ng keeps the idea that the help text is the parser. Cleo is the command-class framework behind Poetry, with a console layer built for long-lived, many-command applications. Each makes a different trade, and knowing those trades helps you choose deliberately — or recognise the framework in a codebase you have just inherited.
This topic surveys the four, shows each one doing the same job, and compares them on the things that matter in production: how arguments are declared, how types are converted, what happens on bad input, how you test, and what exit codes you get. It sits in the Modern Python CLI Frameworks & Architecture section, next to command-line parsing with argparse and Typer vs Click: when to use each, which remain the reference points.
TL;DR
- Cyclopts — type hints and docstrings define the interface, like Typer, but with first-class
Literal, unions, dataclasses, config sources and docstring-parsed help. A strong choice for new type-hinted CLIs. - Python Fire — exposes functions, classes or modules with no declarations at all. Ideal for a team's internal scripts and exploration; too loose for a public tool, because it does not enforce types.
- docopt-ng — the usage text you write is parsed into a grammar. Elegant for small tools with a stable interface; every value arrives as a string, and validation is yours.
- Cleo — commands are classes with declared arguments and options, plus rich console I/O, verbosity levels and testers. Good for large applications that want structure over decorators; it powers Poetry.
- Click and Typer remain the default for most projects because of their ecosystem: plugins, completion, testing helpers and documentation. Pick an alternative for a concrete reason.
Four ways to declare an interface
Every CLI framework answers one question: where does the description of the command line live? The answer shapes everything downstream — how much code you write, where the help comes from, and how much the parser knows about types.
- Decorators or builder calls (Click, argparse). You describe each parameter explicitly, next to the function or on a parser object. Verbose, unambiguous, and very flexible.
- Type-annotated signatures (Typer, Cyclopts). The function signature is the declaration; annotations drive conversion and validation, and docstrings or annotations supply help.
- Usage text (docopt). You write the help message in a conventional format and the library derives a parser from it.
- Introspection with no declaration (Fire). The library inspects whatever object you hand it and exposes its callables and parameters as commands and flags.
A fifth model, command classes (Cleo, and Cement or Cliff in the wider ecosystem), sits between the first two: each command is a class with declared arguments and a handle() method, which suits applications with dozens of commands sharing services.
The further you move from explicit declaration, the less code you write and the less the framework can check for you. Fire knows nothing about the type of a parameter unless the value happens to parse as a Python literal; Click knows exactly what you told it. That single axis explains most of the differences in the rest of this topic.
The same command in each framework
A small command makes the comparison concrete: add TEXT [--tag TAG]... [--priority 1-5].
Cyclopts reads the signature and the NumPy- or Google-style docstring:
from typing import Annotated
from cyclopts import App, Parameter, validators
app = App(name="notes", version="1.2.0")
@app.command
def add(
text: str,
*,
tag: Annotated[list[str] | None, Parameter(name=["--tag", "-t"])] = None,
priority: Annotated[int, Parameter(validator=validators.Number(gte=1, lte=5))] = 3,
):
"""Add a note.
Parameters
----------
text
The note text.
tag
Tags to attach; repeat the flag.
priority
1 (low) to 5 (high).
"""
Fire needs nothing but the function — and enforces nothing either:
import fire
def add(text: str, tag: tuple = (), priority: int = 3):
"""Add a note."""
if __name__ == "__main__":
fire.Fire({"add": add})
docopt-ng parses a usage string:
"""Notes.
Usage:
notes add <text> [--tag=<tag>]... [--priority=<n>]
Options:
--priority=<n> 1 (low) to 5 (high) [default: 3].
"""
from docopt import docopt
args = docopt(__doc__) # {'<text>': ..., '--tag': [...], '--priority': '3', ...}
Cleo declares a command class:
from cleo.commands.command import Command
from cleo.helpers import argument, option
class AddCommand(Command):
name = "add"
description = "Add a note."
arguments = [argument("text", description="The note text.")]
options = [
option("tag", "t", description="Tags to attach.", flag=False, multiple=True),
option("priority", None, description="1 (low) to 5 (high).", flag=False, default="3"),
]
def handle(self) -> int:
priority = int(self.option("priority"))
...
return 0
Notice where validation lives. Cyclopts enforces the 1–5 range before your function runs. Fire, docopt and Cleo hand you a value — a Python literal, a string, a string — and the range check is your code. That is the practical cost of the lighter declaration models.
How they compare in production
Type conversion. Typer and Cyclopts convert from annotations; Cyclopts additionally handles Literal, unions, dataclass and Pydantic models as parameter groups without plugins. Click converts from declared type= objects. Fire evaluates each value as a Python literal if it can, so 07 stays the string "07", 640, becomes a tuple and True a boolean — surprising for users who never wrote Python. docopt returns strings, booleans for flags and lists for repeated items. Cleo returns strings.
Bad input and exit codes. Click, Typer, Cyclopts and Fire exit with code 2 on a usage error, matching the convention scripts expect. docopt-ng and Cleo exit with 1, which a wrapper script cannot distinguish from a runtime failure. If exit codes matter to your users — see choosing exit codes for CLI tools — wrap the entry point or check for it in tests.
Help output. Cyclopts renders Rich-formatted help panels from docstrings; Fire generates man-page-style help from introspection; docopt's help is exactly the text you wrote; Cleo prints a Symfony-style command list with namespaces (db migrate) and "did you mean" suggestions.
Shell completion. Click and Typer ship completion for bash, zsh and fish. Cyclopts can generate and install completion scripts. Fire prints a bash completion script with mytool -- --completion. docopt has no built-in completion. Cleo ships a CompletionsCommand you register on the application (Poetry exposes it as poetry completions). If completion is a must-have, test it early with testing shell completion in mind.
Testing. Click and Typer have CliRunner. Cyclopts apps are callable with a token list and can return the command's value directly. Fire accepts command=[...] and returns the result. docopt accepts argv=[...]. Cleo has CommandTester and ApplicationTester with captured input and output.
Choosing one
Start from the default and look for a concrete reason to move.
- New public tool, type-hinted codebase: Typer or Cyclopts. Choose Cyclopts if you hit Typer's limits with unions,
Literal, dataclass parameters or config-file integration, or prefer docstring-driven help; choose Typer for its larger ecosystem and Click compatibility. Building a CLI with Cyclopts walks through a full example. - Internal script you want callable from the shell today: Fire. It turns a module of functions into a CLI in one line; accept that it is loose and do not ship it to customers. See quick CLIs from functions with Python Fire.
- Small tool where the usage text is the spec: docopt-ng, as long as you are happy validating strings yourself. Usage-string driven CLIs with docopt-ng shows how to pair it with a validation layer.
- Large application with many commands, namespaces and shared services: Cleo, or Click with a class-based structure. Building a CLI with Cleo covers commands, I/O and testers.
- Zero dependencies: argparse, still. Every alternative here is a third-party package.
Keeping the framework replaceable
Whichever you pick, the advice from how to structure a large Python CLI project applies doubly with less common frameworks: keep the framework in a thin outer layer. Commands parse and validate input, then call plain functions that know nothing about Cyclopts, Fire or Cleo. Business logic tested without the CLI survives a framework change; logic tangled with self.option() calls does not.
# core.py — no framework imports
def add_note(store, text: str, tags: list[str], priority: int) -> int:
if not 1 <= priority <= 5:
raise ValueError("priority must be between 1 and 5")
return store.insert(text=text, tags=tags, priority=priority)
With that split, switching frameworks — or supporting two entry points during a migration, as in migrating from argparse to Typer — is a matter of rewriting the outer layer.
Testing looks different in each framework
The shape of a framework's test support says a lot about how it expects to be used, and it is worth checking before you commit, because tests are where you spend time every week.
- Click and Typer offer
CliRunner, which invokes a command in-process, captures stdout and stderr, and returns an exit code and exception. Tests assert on text and codes; see testing Click commands with CliRunner. - Cyclopts apps are callable:
app(["add", "x"], result_action="return_value")returns whatever the command returned, so many tests can assert on Python values rather than parsing output. Parse errors surface as exceptions whenexit_on_error=False. - Fire accepts
command=[...]and returns the result too. Because Fire does not validate types, the most valuable tests are the ones that pin what a function receives for awkward input. - docopt-ng is pure parsing:
docopt(doc, argv=[...])returns the dictionary. Everything after that is your code, tested like any other function — which is why the docopt guide structures the program asparse(), a conversion layer andmain(argv). - Cleo provides
CommandTesterfor a single command andApplicationTesterfor the whole application, both with simulated input for questions and separate captured streams.
Whatever the framework, the advice from the testing topic holds: test business logic without the CLI, test the CLI layer for parsing, exit codes and stream separation, and keep at least one end-to-end test that runs the installed command.
Startup time and dependency weight
Framework choice also affects how fast the tool starts, which users feel on every invocation and especially in shell completion. The differences are mostly about what each framework imports at startup:
- docopt-ng is a single small module with no dependencies, so it adds almost nothing.
- Fire imports its own modules plus
termcolor; it is light, but it introspects the component on every run. - Click is moderate; Typer adds Rich for help and error formatting, which is the largest share of its import time unless help is rendered.
- Cyclopts also depends on Rich and on
attrs, and imports them lazily where it can. - Cleo is moderate and supports lazy command loading for large applications.
For most tools the framework is not the bottleneck — your own imports are. Measure rather than guess, with the techniques in profiling Python CLI startup time, before choosing a framework for speed.
Evaluating a framework you have not used
New CLI frameworks appear every year, and most are pleasant in a ten-line demo. A short, repeatable evaluation separates the ones that hold up. Build the same small tool in each candidate — one command with a required argument, a repeatable option, a choice option, a boolean flag and a subcommand group — and check these points in order:
- Bad input. Pass a wrong type, an unknown option and a missing argument. Is the message clear, does it go to stderr, and is the exit code 2?
- Help. Does
--helpshow defaults, choices and descriptions without duplication in your code? Does it look reasonable at 80 columns and in a pipe? - Types. Do
Path,EnumorLiteral, optional values and lists behave as the annotations or declarations say? - Testing. Can you invoke a command in-process, capture both streams and inspect the exit code, without monkeypatching
sys.argv? - Completion. Can users install completion for their shell, and can you test it?
- Maintenance. When was the last release, how many people contribute, and does it support the newest Python version?
Half an hour of this tells you more than any feature list, and the throwaway tool doubles as a reference when you write the real one.
Common pitfalls
- Shipping Fire to end users. Its literal parsing, permissive flags and Python-flavoured help confuse people who are not Python developers, and every public method becomes a command. Wrap the functions you want in an explicit dictionary at least.
- Trusting docopt values.
--priority=abcparses fine and fails three functions later. Validate the dictionary immediately after parsing. - Ignoring exit codes. docopt-ng and Cleo return
1for usage errors. Decide whether that matters and test for whatever you choose. - Mixing frameworks. Adding a Click group to a Cleo app, or a Typer sub-app to a Cyclopts app, doubles the dependency weight and splits the help output. Choose one per executable.
- Picking on novelty. An unfamiliar framework costs every future contributor some learning. The benefit should be concrete: less code, a feature you need, or a better fit for the codebase.
Key takeaways
- Frameworks differ mainly in where the interface is declared: decorators, type hints, usage text, introspection or command classes.
- Less declaration means less code and less validation; budget for doing that validation yourself.
- Cyclopts is the strongest alternative for type-hinted public tools; Fire is excellent for internal scripts; docopt suits small, stable tools; Cleo suits large, namespaced applications.
- Check exit codes, completion and testing support before committing — they differ more than the parsing does.
- Keep business logic framework-free so the choice stays reversible.
Frequently asked questions
Is docopt still maintained?
The original docopt package has not had a release in years. docopt-ng is the maintained fork with type hints and bug fixes, and it is a drop-in replacement (from docopt import docopt). Use it for new code and when modernising an old project.
How does Cyclopts differ from Typer in practice?
Both build the CLI from type hints. Cyclopts supports more types natively (unions, Literal, dataclasses, Pydantic models), parses help from docstrings, makes keyword-only parameters into options and positional ones into arguments, and has built-in config sources such as environment variables and TOML files. Typer builds on Click, which gives it Click's ecosystem and plugins. The Typer vs Click guide covers the Click side of that trade.
Can I use Fire for a quick prototype and switch later?
Yes, and it is a good workflow: expose functions with Fire while the interface is still moving, then write explicit Typer or Cyclopts commands once it settles. Because Fire needs no declarations, the functions are already framework-free.
Why does Poetry use Cleo instead of Click?
Cleo comes from the same author's ecosystem and models commands as classes with namespaces, verbosity levels and testers — a structure that suits an application with many commands and shared I/O. It is a design preference rather than a capability gap.
What about argh, plac, clize and others?
They exist and work, mostly as thin layers over argparse that derive arguments from function signatures. They are less widely used than the frameworks above, so you get less community help. Evaluate them with the same criteria: declaration model, validation, exit codes, completion and testing.