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.
- A positional-or-keyword parameter becomes a positional argument that can also be passed by name (
notes add "milk"ornotes add --text "milk"). - A keyword-only parameter (after
*) becomes an option only. - A default value makes it optional; no default makes it required.
- A
boolbecomes a flag with an automatic negative form (--dry-run/--no-dry-run). - A
list[...]accepts the option repeatedly. Literal["json", "csv"]or anEnumrestricts 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.
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=toParameteras 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.Envon the app, help shows an[env var: NOTES_…]hint next to every parameter — including the requiredTEXT, 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.
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. UseNoneand 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
Nonefrom commands that print. - Over-broad config sources. An
Envconfig 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.