Architecture

Beyond Click and Typer: Alternative Python CLI Frameworks

Cyclopts, Python Fire, docopt-ng and Cleo compared with Click and Typer: parsing models, type handling, testing, exit codes and when each is the better choice.

Updated

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.

Frameworks beyond Click and Typer The alternative frameworks covered in this topic: Cyclopts, Python Fire, docopt-ng and Cleo, each with its own declaration style. Frameworks beyond Click and Typer Alternative frameworks same job, different trade-offs Cyclopts type hints + docstrings Python Fire introspect any object docopt-ng usage text is the parser Cleo command classes each framework has its own in-depth guide Click and Typer stay the reference point; each alternative earns its place with a specific strength.

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.

Where the interface is declared Five ways CLI frameworks declare a command line, ordered from most explicit to least explicit. Where the interface is declared more explicit, more checking Decorators or builder calls Click, argparse every parameter spelled out next to the function or on a parser Command classes Cleo arguments and options as class attributes, work in handle() Type-annotated signatures Typer, Cyclopts hints drive conversion, docstrings or metadata supply help Usage text docopt-ng the help message is parsed into a grammar Introspection only Fire whatever object you pass becomes the interface less code, less validation The less you declare, the less the framework can check for you.
  1. 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.
  2. Type-annotated signatures (Typer, Cyclopts). The function signature is the declaration; annotations drive conversion and validation, and docstrings or annotations supply help.
  3. Usage text (docopt). You write the help message in a conventional format and the library derives a parser from it.
  4. 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

The alternatives side by side A comparison of Cyclopts, Python Fire, docopt-ng and Cleo on type conversion, usage error exit code, completion and testing support. The alternatives side by side Framework Types converted Usage error exit Testing Cyclopts from hints, incl. Literal 2 call app with tokens Python Fire Python literals only 2 Fire(obj, command=[…]) docopt-ng none — strings 1 (fixable) docopt(doc, argv=[…]) Cleo none — strings 1 CommandTester Click / Typer declared or hinted 2 CliRunner Exit codes differ more than parsing does — check them before committing.

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

Picking an alternative A decision guide choosing between Cyclopts, Python Fire and Cleo depending on what kind of tool is being built. Picking an alternative What are you building? A public, type-hinted tool Cyclopts or Typer for its ecosystem Internal scripts, today Fire wrap functions, keep it in-house Many namespaced commands Cleo or Click with classes docopt-ng fits small tools with a stable interface; argparse still wins when dependencies are not allowed.

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.

How each framework is tested The in-process testing entry point for Click and Typer, Cyclopts, Python Fire, docopt-ng and Cleo, and what a test can assert on. How each framework is tested Framework Invoke with Assert on Click / Typer CliRunner().invoke(app, [...]) output, exit code Cyclopts app([...], result_action=…) returned values Python Fire fire.Fire(obj, command=[...]) returned values docopt-ng docopt(doc, argv=[...]) the parsed dict Cleo CommandTester(cmd).execute() both streams, code Asserting on returned values is less brittle than parsing printed text.
  • 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 when exit_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 as parse(), a conversion layer and main(argv).
  • Cleo provides CommandTester for a single command and ApplicationTester for 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:

  1. 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?
  2. Help. Does --help show defaults, choices and descriptions without duplication in your code? Does it look reasonable at 80 columns and in a pipe?
  3. Types. Do Path, Enum or Literal, optional values and lists behave as the annotations or declarations say?
  4. Testing. Can you invoke a command in-process, capture both streams and inspect the exit code, without monkeypatching sys.argv?
  5. Completion. Can users install completion for their shell, and can you test it?
  6. 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=abc parses fine and fails three functions later. Validate the dictionary immediately after parsing.
  • Ignoring exit codes. docopt-ng and Cleo return 1 for 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.