mrkeyoor.com_
Thu 06 Aug 07:41 UTC
PyPIUtilsupdated 06 Aug 2026

cyclopts

cyclopts builds command-line interfaces out of ordinary annotated Python functions. You write a function with type hints and a docstring, register it with @app.command, and cyclopts derives the arguments, the options, the choices, the defaults, and the entire help page from what is already there. It reads numpydoc, Google, and reST docstrings for parameter descriptions instead of making you repeat every help string inside a decorator. Its type support goes further than the older frameworks: unions, Literal, Enum, Path, dataclasses, attrs classes, and Pydantic models all convert and validate without custom parsers, so an argument annotated int | Literal['default', 'performance'] gets both the number parsing and the two named choices printed in help. Help output is rendered through rich, so panels and colors come standard. It also has configuration file loading (TOML, YAML, JSON), environment variable binding, shell completion for bash, zsh, and fish, and an interactive shell mode. Python 3.10 or later is required.

Verdict

The best type-hint-driven CLI framework available right now if your floor is Python 3.10, because the docstring-derived help and real union support remove boilerplate that Typer still requires. The trade is a smaller community and a fast release train, so pin the version and expect to read the docs rather than search results.

API stability3/5App, Parameter, and the command decorators have been consistent through 4.x, but 153 releases, several minors inside a single month, and a 5.0.0 beta published 6 August 2026 mean an unpinned dependency will move under you; the 4.x to 5.x jump has not landed yet, so the migration cost is still unknown.
Docs5/5The ReadTheDocs site has a real tutorial, an API reference generated from the source, and dedicated pages for config files, validators, groups, and meta apps, plus a side-by-side Typer comparison that is candid about what each framework can and cannot express.
Maintenance5/5Repo pushed 6 August 2026, 4.22.5 released two days earlier, and only 17 open issues (20 counting PRs); the tracker shows issues getting fixed and released within days, though that pace comes from essentially one maintainer.
Ecosystem3/5About 26.6M weekly downloads but only 1.2k stars, which says most of that volume arrives as a transitive dependency rather than from teams choosing it; there is no plugin ecosystem, and because it is not click-based, nothing written for click works here.

Use it if

  • You want the help page to come from your docstring rather than from a second copy of the same text inside decorator arguments
  • Your parameters have types that older frameworks cannot express: unions, Literal choices mixed with a real type, nested dataclasses, or Pydantic models bound directly to CLI flags
  • You want config file and environment variable support without wiring it yourself: App(config=[cyclopts.config.Env('MYAPP_'), cyclopts.config.Toml('pyproject.toml')]) covers the usual precedence chain
  • You are writing a small tool and want the shortest possible path: cyclopts.run(my_function) turns one function into a working CLI with no App object at all
  • You want validation at parse time rather than in the function body, using cyclopts.validators.Number, cyclopts.validators.Path, or the prebuilt annotated types like PositiveInt and ExistingFile
  • You are already frustrated with Typer specifically: the README is explicit that cyclopts exists because Typer cannot express unions, cannot read docstrings for help, and needs Annotated boilerplate for cases cyclopts handles directly
Skip it if

Setup reality

pip install cyclopts pulls rich, attrs, docstring-parser, and rich-rst, plus tomli and typing-extensions on Python 3.10, so it is not a zero-dependency add. YAML config needs the extra: pip install 'cyclopts[yaml]'. Nothing needs compiling and there are no native wheels to worry about. The friction is conceptual rather than mechanical. Your docstring is now part of your user interface, so a parameter renamed in the signature but not in the docstring quietly loses its help text, and docstring-parser has to recognize your style (numpydoc, Google, or reST) or descriptions come out blank. Parameter names are transformed to CLI names automatically, so a parameter called dry_run becomes --dry-run and a bool gets a --no-dry-run counterpart you did not write; overriding that means Annotated[bool, Parameter(name='--force', negative=())]. Groups, validators, and config sources each sit on a different object (App, Group, Parameter), and figuring out which one a given behavior belongs on is most of the learning curve. Finally, config resolution order is the order you list sources in App(config=[...]), and getting that backwards is the classic first bug.

Patterns

Turn one function into a CLIsingle-function-cli

from cyclopts import run

def resize(path: str, width: int, height: int = 1080):
    """Resize an image.

    Parameters
    ----------
    path
        Image to resize.
    width
        Target width in pixels.
    height
        Target height in pixels.
    """
    print(path, width, height)

run(resize)

run() is the shortcut when there is exactly one command and no subcommands. The moment you need a second command or a --version flag, switch to App; run() has no place to hang that configuration.

Register subcommands on an Appapp-with-commands

from cyclopts import App

app = App(name='mytool', version='1.4.0')

@app.command
def build(target: str, *, clean: bool = False):
    """Build a target."""

@app.command(name='deploy')
def deploy_cmd(env: str):
    """Ship it."""

@app.default
def show_status():
    """Runs when no command is given."""

if __name__ == '__main__':
    app()

The command name comes from the function name with underscores turned into hyphens unless you pass name explicitly. @app.default handles the bare invocation; without it, running the tool with no arguments prints help.

Accept either a number or a named presetliteral-and-union

from typing import Literal
from cyclopts import App

app = App()

@app.command
def deploy(
    env: Literal['dev', 'staging', 'prod'],
    replicas: int | Literal['default', 'performance'] = 'default',
):
    """Deploy code to an environment."""
    if replicas == 'default':
        replicas = 10
    elif replicas == 'performance':
        replicas = 20

Union members are tried in order, so put the stricter type first: str | int would swallow every value as a string. This exact signature is the README's main argument against Typer, which cannot represent it.

Control the flag name and short aliasrename-and-alias

from typing import Annotated
from cyclopts import App, Parameter

app = App()

@app.command
def sync(
    src: str,
    *,
    verbose: Annotated[bool, Parameter(alias='-v')] = False,
    force: Annotated[bool, Parameter(negative=())] = False,
    api_key: Annotated[str, Parameter(env_var='MYTOOL_API_KEY')] = '',
):
    """Sync a source."""

Every bool gets an auto-generated --no-<name> flag; negative=() removes it. Keyword-only parameters (after the bare *) become options, positional-or-keyword parameters accept both a position and a --flag.

Reject bad values before the function runsvalidate-values

from pathlib import Path
from typing import Annotated
from cyclopts import App, Parameter, validators

app = App()

@app.command
def ingest(
    src: Annotated[Path, Parameter(validator=validators.Path(exists=True, dir_okay=False))],
    workers: Annotated[int, Parameter(validator=validators.Number(gte=1, lte=64))] = 4,
):
    """Ingest a file."""

Validators run during parsing, so failures print a rich error panel and exit instead of raising a traceback from inside your function. cyclopts.types already bundles the common combinations, so PositiveInt and ExistingFile save you from writing these by hand.

Use the bundled constrained typesprebuilt-types

from cyclopts import App
from cyclopts.types import ExistingFile, PositiveInt, ResolvedDirectory

app = App()

@app.command
def convert(
    source: ExistingFile,
    out_dir: ResolvedDirectory,
    quality: PositiveInt = 80,
):
    """Convert a file."""

These are plain Annotated aliases, so they work with mypy and are interchangeable with the hand-written validator version above. Resolved* variants call Path.resolve() for you, which matters when the tool changes working directory.

Layer defaults from env vars and a config fileconfig-files-and-env

import cyclopts
from cyclopts import App

app = App(
    name='mytool',
    config=[
        cyclopts.config.Env('MYTOOL_'),
        cyclopts.config.Toml('pyproject.toml', root_keys=('tool', 'mytool'), search_parents=True),
    ],
)

Sources are consulted in list order and CLI tokens always win over all of them, so put the highest-priority source first. Toml() does not error on a missing file unless you pass must_exist=True, which makes a typo in the path look like an empty config.

Group options in help and validate them togethergroup-parameters

from typing import Annotated
from cyclopts import App, Group, Parameter, validators

app = App()
auth = Group('Authentication', validator=validators.MutuallyExclusive())

@app.command
def fetch(
    url: str,
    *,
    token: Annotated[str, Parameter(group=auth)] = '',
    netrc: Annotated[bool, Parameter(group=auth)] = False,
):
    """Fetch a URL."""

A Group is both a help panel and a place to hang a cross-parameter validator, which is the only clean way to express 'exactly one of these'. LimitedChoice and all_or_none cover the other common shapes.

Bind a dataclass or Pydantic model to flagsbind-dataclass

from dataclasses import dataclass
from cyclopts import App

@dataclass
class DbConfig:
    host: str = 'localhost'
    port: int = 5432
    ssl: bool = False

app = App()

@app.command
def migrate(db: DbConfig, *, dry_run: bool = False):
    """Run migrations."""

# mytool migrate --db.host=prod.internal --db.port=6543 --db.ssl

Fields become dotted flags, and nesting another dataclass inside adds another dot level. Attrs classes and Pydantic models work the same way, with Pydantic's own validators running after cyclopts finishes converting.

Add global options shared by every commandmeta-app-globals

from cyclopts import App

app = App(name='mytool')

@app.meta.default
def launcher(*tokens: str, verbose: bool = False, profile: str = 'default'):
    configure_logging(verbose)
    load_profile(profile)
    return app(tokens)

if __name__ == '__main__':
    app.meta()

The meta app parses its own options first and forwards the rest, which is how you get --verbose working before any subcommand runs. Call app.meta() as the entry point, not app(), or the global flags are never parsed.

Ship shell completionshell-completion

from cyclopts import App

app = App(name='mytool')

@app.command
def completion(shell: str | None = None):
    """Print a completion script for bash, zsh, or fish."""
    print(app.generate_completion(shell=shell))

# or write it into the shell's startup files directly
# app.install_completion(shell='zsh')

generate_completion() raises ValueError when the App has no name, and raises ShellDetectionError when shell is None and it cannot detect the shell, so pass shell explicitly in anything non-interactive.

Test a CLI without spawning a processtest-a-command

from cyclopts import App, MissingArgumentError
import pytest

app = App()

@app.command
def greet(name: str, *, loud: bool = False):
    return f'HELLO {name}' if loud else f'hello {name}'

def test_greet():
    assert app(['greet', 'ada', '--loud'], exit_on_error=False) == 'HELLO ada'

def test_missing_arg():
    with pytest.raises(MissingArgumentError):
        app(['greet'], exit_on_error=False)

Calling the App with a token list returns the command's return value, which makes assertions straightforward. Pass exit_on_error=False or a parse failure calls sys.exit and your test dies instead of failing.

Alternatives

PackageRegistryPick it when
typerPyPIYou want the same type-hint style with a much larger community, FastAPI-adjacent familiarity, and click underneath, and you can live without union and Literal support
clickPyPIYou want the most widely deployed Python CLI framework, its plugin ecosystem, and CliRunner-based tests, and you do not mind declaring options in decorators
argparse-dataclassPyPIYou want to keep the standard library's argparse and only need dataclass-shaped arguments on top of it