Architecture

Building a Type-Hinted Python CLI with Cyclopts

Build a multi-command CLI with Cyclopts: signatures as the interface, docstring help, Literal and Path types, validators, env config, sub-apps and tests.

Updated

Cyclopts builds a command-line interface from ordinary Python functions: positional parameters become arguments, keyword-only parameters become options, type hints drive conversion and validation, and the docstring becomes the help text. If that sounds like Typer, it is the same idea — and Cyclopts was written by someone who liked Typer's idea and wanted it to go further: native support for Literal, unions and dataclasses, help parsed from docstrings rather than repeated in help= strings, and configuration sources built in. This guide builds a small notes tool with two commands, a sub-app, validation and environment-variable defaults, then tests it with pytest. It belongs to the alternative Python CLI frameworks topic.

Prerequisites

  • Python 3.10+ and cyclopts (uv add cyclopts). The examples were checked against Cyclopts 5.
  • Comfort with type hints and typing.Annotated.
  • If you know Typer, keep using Annotated options in Typer nearby for comparison.

How Cyclopts reads a function

The mapping rules are short, and knowing them makes every later example predictable.

How Cyclopts reads a signature How parts of a Python function signature map to command line arguments and options in Cyclopts. How Cyclopts reads a signature In the signature On the command line text: str positional TEXT (or --text) *, dry_run: bool = False --dry-run / --no-dry-run tag: list[str] | None --tag repeated fmt: Literal["json", "csv"] choices listed in help docstring Parameters per-parameter help text Keyword-only parameters become options; everything else can also be passed positionally.
  • A positional-or-keyword parameter becomes a positional argument that can also be passed by name (notes add "milk" or notes add --text "milk").
  • A keyword-only parameter (after *) becomes an option only.
  • A default value makes it optional; no default makes it required.
  • A bool becomes a flag with an automatic negative form (--dry-run / --no-dry-run).
  • A list[...] accepts the option repeatedly.
  • Literal["json", "csv"] or an Enum restricts choices and lists them in help.
  • The docstring's parameter section (NumPy, Google, Sphinx or Epydoc style) supplies the per-parameter help.

The recipe

# src/notes/cli.py
from __future__ import annotations

from pathlib import Path
from typing import Annotated, Literal

from cyclopts import App, Parameter, config, validators

app = App(
    name="notes",
    help="Keep short notes from the terminal.",
    version="1.2.0",
    config=config.Env("NOTES_", command=False),   # NOTES_PRIORITY=4 sets --priority
    result_action="return_int_as_exit_code_else_zero",  # print in commands, return data
)

STORE: list[dict] = []


@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,
) -> dict:
    """Add a note.

    Parameters
    ----------
    text
        The note text.
    tag
        Tags to attach. Repeat the option for several tags.
    priority
        1 (low) to 5 (high).
    """
    note = {"id": len(STORE) + 1, "text": text, "tags": tag or [], "priority": priority}
    STORE.append(note)
    print(f"added note {note['id']}")
    return note


@app.command
def export(
    dest: Annotated[Path, Parameter(validator=validators.Path(dir_okay=False))],
    *,
    fmt: Literal["json", "csv"] = "json",
    dry_run: bool = False,
) -> None:
    """Export all notes to a file.

    Parameters
    ----------
    dest
        File to write.
    fmt
        Output format.
    dry_run
        Print what would be written without writing it.
    """
    action = "would write" if dry_run else "wrote"
    print(f"{action} {len(STORE)} notes to {dest} as {fmt}")


tags = App(name="tags", help="Inspect tags.")
app.command(tags)


@tags.command(name="list")
def list_tags() -> list[str]:
    """List every tag in use."""
    names = sorted({t for note in STORE for t in note["tags"]})
    for name in names:
        print(name)
    return names


if __name__ == "__main__":
    raise SystemExit(app())

A few things are worth noticing. list[str] | None with a None default gives a repeatable --tag option that is optional; Cyclopts also generates an --empty-tag form for explicitly passing an empty list. The range check runs before add is called, so the function body never sees priority=9. The tags sub-app is just another App registered as a command, which is how Cyclopts builds nested groups — compare building a CLI with subcommands in Click. And config.Env("NOTES_", command=False) reads defaults from NOTES_<PARAMETER>; with the default command=True the variable name also includes the command, as in NOTES_ADD_PRIORITY.

The notes tool in use Terminal session adding a note with tags, being rejected for an out-of-range priority, and listing tags from a sub-app. The notes tool in use bash $ notes add "buy milk" -t home -t errand --priority 5 added note 1 $ notes add "x" --priority 9 │ Invalid value "9" for --priority. Must be <= 5. │ $ echo $? 2 $ NOTES_PRIORITY=4 notes add "call Bo" added note 2 Validation happens before the function runs, and the error goes to stderr with exit code 2.

Return values and exit codes

Cyclopts passes each command's return value to a result action. The default prints any non-integer return value and treats an integer as the exit code — convenient for tiny scripts, but it means a command that prints its own output and returns data shows everything twice. The recipe sets result_action="return_int_as_exit_code_else_zero" on the app instead: commands print what users should see, return data for callers and tests, and only an integer return becomes an exit status. Tests then override it per call with result_action="return_value" to assert on the returned object instead of parsing stdout.

Grouping parameters with a dataclass

Commands that share a set of options — connection settings, output settings — can declare them once as a dataclass and accept it as a single parameter. Cyclopts flattens the dataclass fields into options, so users see ordinary flags while the function receives one typed object:

from dataclasses import dataclass

from cyclopts import Parameter


@Parameter(name="*")            # flatten: --host, not --conn.host
@dataclass
class Connection:
    host: str = "localhost"
    """Server to connect to."""
    port: int = 5432
    """TCP port."""


@app.command
def ping(*, conn: Connection | None = None) -> str:
    """Check that the server answers."""
    conn = conn or Connection()
    return f"{conn.host}:{conn.port}"

notes ping --host db.internal --port 6543 now fills the dataclass, and the same Connection can be reused by every command that talks to the server. It is the Cyclopts counterpart of the reusable option decorators described in sharing common options across commands, with the advantage that the grouped values travel together as one validated object. Field docstrings become the help text for each flag.

UX considerations

  • Keyword-only by default. Putting most parameters after * makes them options, which is easier to read in scripts and safer to extend: adding a new positional argument later is a breaking change, adding an option is not. Positional arguments vs options covers the reasoning.
  • Write the docstring once. Help text lives in one place and stays next to the code it describes; resist adding help= to Parameter as well.
  • Name choices with Literal. It shows the allowed values in help and in errors, and type checkers see the same constraint as the parser.
  • Usage errors exit with 2. Cyclopts prints a Rich error panel to stderr and exits with code 2 on a bad value, matching Click and argparse, so wrapper scripts can tell a typo from a runtime failure.
  • Scope environment defaults deliberately. With config.Env on the app, help shows an [env var: NOTES_…] hint next to every parameter — including the required TEXT, which can now come from the environment too. If that is more than you want, attach the config to fewer parameters or use a narrower source.
Cyclopts and Typer compared A comparison of Cyclopts and Typer on help text source, supported types, configuration sources, global options and ecosystem. Cyclopts and Typer compared Feature Typer Cyclopts Help text help= or docstring summary parsed docstring sections Literal, unions limited native Config sources envvar= per option Env, Toml, Yaml, Json Global options @app.callback() meta app Built on Click (vendored) its own parser Same core idea, different depth: Typer inherits Click’s ecosystem, Cyclopts goes further with types.

Common pitfalls

  • Positional by accident. A parameter before * can be passed positionally, so reordering the function signature changes the command line. Make the boundary explicit and keep only true arguments before it.
  • Mutable defaults. As in any Python function, tag: list[str] = [] shares one list between calls. Use None and normalise inside the function, as the recipe does — it matters in tests that call the app repeatedly.
  • Return values printed twice. Under the default result action, a command that prints its result and returns it shows the output twice. Set an app-wide result action as the recipe does, or return None from commands that print.
  • Over-broad config sources. An Env config on the whole app can satisfy required arguments from stray environment variables in CI. Scope it to the options that should be configurable.

Testing the behaviour

A Cyclopts App is callable with a list of tokens. In tests, turn off the process exit and ask for the return value:

# tests/test_cli.py
import pytest
from cyclopts.exceptions import CycloptsError

from notes import cli


@pytest.fixture(autouse=True)
def empty_store():
    cli.STORE.clear()


def run(*tokens):
    return cli.app(list(tokens), result_action="return_value",
                   exit_on_error=False, print_error=False)


def test_add_converts_and_collects_tags():
    note = run("add", "buy milk", "-t", "home", "-t", "errand", "--priority", "5")
    assert note == {"id": 1, "text": "buy milk", "tags": ["home", "errand"], "priority": 5}


def test_priority_out_of_range_is_rejected():
    with pytest.raises(CycloptsError):
        run("add", "x", "--priority", "9")


def test_literal_choices_are_enforced():
    with pytest.raises(CycloptsError):
        run("export", "out.json", "--fmt", "xml")


def test_env_var_supplies_default(monkeypatch):
    monkeypatch.setenv("NOTES_PRIORITY", "4")
    assert run("add", "x")["priority"] == 4


def test_sub_app_command(capsys):
    run("add", "a", "-t", "b")
    run("add", "c", "-t", "a")
    assert run("tags", "list") == ["a", "b"]
    assert capsys.readouterr().out.endswith("a\nb\n")


def test_usage_error_exits_with_2():
    with pytest.raises(SystemExit) as exc:
        cli.app(["add"], print_error=False)
    assert exc.value.code == 2

The last test runs with the normal exit behaviour, pinning the exit code users and scripts see. For an end-to-end check of the installed command, the approach in end-to-end testing an installed CLI works unchanged.

Conclusion

Cyclopts keeps the best part of Typer — the function signature is the interface — and adds richer types, docstring-driven help and built-in configuration sources. Write commands as plain functions with keyword-only options, let validators reject bad input before your code runs, register sub-apps for nesting, and test by calling the app with tokens and result_action="return_value". If you need Click's plugin ecosystem, Typer is still the better fit; otherwise Cyclopts is a strong default for new type-hinted tools.

Frequently asked questions

How do I add global options such as --verbose to every command?

Use the meta app: decorate a function with @app.meta.default that takes the global options plus *tokens, configures logging, and then calls app(tokens). Run the program through app.meta() instead of app(). It plays the role of a Typer callback or a Click group function.

Can Cyclopts read a TOML config file?

Yes. config.Toml("pyproject.toml", root_keys=["tool", "notes"]) or a dedicated file adds a config layer, and several sources can be combined in a list. Keep the precedence order explicit and documented, as in config precedence: flags, env, files, defaults.

Does Cyclopts support async commands?

Yes — decorate an async def function and Cyclopts runs it in an event loop (asyncio by default, Trio optionally). For cancellation and Ctrl-C behaviour, the patterns in running async code in Typer and Click still apply.

How hard is it to migrate from Typer?

Usually mechanical: replace typer.Option(...) metadata with Parameter(...), move help strings into docstrings, and turn @app.callback() logic into a meta app. Keep business logic outside the command functions and the migration touches only the outer layer.