Migrating to v5

Most applications need no changes.

v5's breaking changes are concentrated in Meta Apps, which now parse hierarchically (see Parse Mode). Everything else is an edge case you were probably not relying on.

If your app...

See

uses app.meta to forward tokens

Meta Apps: Parsing Is Now Hierarchical

is invoked by scripts/CI that check exit codes

Parse Errors Exit With Code 2

has command functions named PascalCase or snake_case

Command Names Must Match Exactly

defines a parameter named version or help

User Parameters Shadow --help / --version

still runs on Python 3.10, or installs the toml extra

Python 3.10 Dropped; toml Extra Removed

none of the above

Nothing to do.

New in v5: App.parse_mode, to opt into "strict" meta-app parsing.

Likely To Affect You

Command Names Must Match Exactly

Affects: apps whose command names contain - or _.

v4 retried failed command lookups with dashes/underscores stripped, a compatibility shim for the v4 name-transform change (PascalCase → pascal-case). That shim is removed.

from cyclopts import App

app = App(name="myapp")

@app.command
def MyCommand():  # registers as "my-command"
    print("Hello!")

app()
$ myapp mycommand
# v4: Hello!
# v5: Error: Unknown command "mycommand". Did you mean "my-command"?

To migrate, either type the exact (name-transformed) name, or register the old spelling explicitly:

from cyclopts import App

app = App(name="myapp")

@app.command(alias="mycommand")
def MyCommand():  # invocable as "my-command" or "mycommand"
    print("Hello!")

app()

Parse Errors Exit With Code 2

Affects: scripts and CI that check exit codes.

CLI usage errors (unknown option, missing argument, coercion failure, …) now exit with 2, matching argparse and Click. Previously 1, which made them indistinguishable from a command that ran and reported failure.

from cyclopts import App

app = App(name="myapp")

@app.default
def main(count: int = 0):
    print(count)

app()
$ myapp --bogus; echo $?
# v4: 1
# v5: 2

Meta Apps: Parsing Is Now Hierarchical

Affects: apps that forward tokens through app.meta.

In v4, a meta app claimed any keyword parameter it recognized, from anywhere on the command line. In v5, the default App.parse_mode is "fallthrough": meta parameters may still appear anywhere, but a subcommand takes precedence over the meta for tokens placed after it.

Set parse_mode="strict" to instead bind each parameter only at the level where it appears, and error when a meta parameter is placed after a subcommand. See Parse Mode.

The three consequences below follow from this change.

Subcommands Win On Name Collisions

When the meta and the subcommand define the same name, the subcommand wins for tokens placed after it.

from cyclopts import App, Parameter
from typing import Annotated

app = App(name="myapp")

@app.meta.default
def main(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    verbose: Annotated[bool, Parameter(alias="-v")] = False,
):
    print(f"meta verbose={verbose}")
    app(tokens)

@app.command
def greet(name: str, *, version: Annotated[bool, Parameter(alias="-v")] = False):
    print(f"greet name={name} version={version}")

app.meta()
$ myapp greet -v Alice
# v4: meta verbose=True;  greet name=Alice version=False
# v5: meta verbose=False; greet name=Alice version=True   (child wins)

Placement before the subcommand is unchanged: myapp -v greet Alice binds the meta's verbose in both versions.

Forwarded *tokens Preserve the -- Delimiter

A forwarding meta's raw-stream capture parameter (*tokens with allow_leading_hyphen) now keeps the literal -- the user typed, so the re-parse inside app(tokens) still treats trailing tokens as positional.

from cyclopts import App, Parameter
from typing import Annotated

app = App(name="myapp")

@app.meta.default
def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]):
    print(f"meta captured {tokens}")
    app(tokens)

@app.command
def sub(value: str):
    print(f"sub value={value}")

app.meta()
$ myapp sub -- -x
# v4: meta captured ('sub', '-x')       → Error: Unknown option: -x.
# v5: meta captured ('sub', '--', '-x') → sub value=-x
  • If your wrapper manually re-inserted -- as a workaround, remove it; otherwise the inner parse receives it twice.

  • Leaf commands (those that don't forward) are unchanged: -- is consumed as a marker and never appears in bound values.

A Greedy *args Subcommand Claims Everything After It

If a subcommand's only positional parameter is *args with allow_leading_hyphen, it claims every token after the command. Meta parameters placed after it no longer bubble up.

from cyclopts import App, Parameter
from typing import Annotated

app = App(name="myapp")

@app.meta.default
def main(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    user: str = "default",
):
    print(f"meta user={user}")
    app(tokens)

@app.command
def sub(*args: Annotated[str, Parameter(allow_leading_hyphen=True)]):
    print(f"sub args={args}")

app.meta()
$ myapp sub --user=alice
# v4: meta user=alice;   sub args=()
# v5: meta user=default; sub args=('--user=alice',)

To migrate, either:

  • Place meta parameters before the subcommand: myapp --user=alice sub.

  • Give the subcommand explicit keyword parameters instead of a catch-all.

Edge Cases

User Parameters Shadow --help / --version

Affects: commands defining a parameter named version or help.

Such a parameter now shadows the auto-registered flag on that command: the token binds to your parameter instead of triggering the help/version handler, and the help page lists your parameter.

from cyclopts import App

app = App(name="myapp", version="1.2.3")

@app.command
def sub(*, version: bool = False):
    print(f"version={version}")

app()
$ myapp sub --version
# v4: 1.2.3        (the app version)
# v5: version=True (bound to the parameter)

If you relied on such a parameter not capturing those tokens, rename the parameter or its name.

Note

Shadowing is per-flag:

  • --version shadowed → --help still shows help.

  • --help shadowed → a --version token still triggers the version handler (interception runs before binding), and the shadowed --help token is discarded along with all other tokens.

--version Respects the -- Delimiter

A --version token after the end-of-options delimiter is now positional data instead of triggering version printing, matching --help.

from cyclopts import App

app = App(name="myapp", version="1.2.3")

@app.default
def main(value: str):
    print(f"value={value}")

app()
$ myapp -- --version
# v4: 1.2.3             (command never ran)
# v5: value=--version

result_action Names Are Validated Eagerly

An invalid App.result_action name now raises ValueError at App construction, attribute assignment, or invocation-time override, before any command executes rather than after.

from cyclopts import App

App(result_action="bogus")  # ValueError, raised immediately

The error message lists the valid names.

Help Panels Show Metavars; Positional Labels Left names

Affects: every app's --help output, and custom ColumnSpec renderers.

Keyword-only parameters now show a type-derived value placeholder after their option names (--timeout INT, --config PATH), and the usage line no longer prints BOOL after required flags. Restore the v4 look with App(help_formatter=DefaultFormatter(show_metavar=False)), or override individual placeholders with Parameter.metavar. Custom layouts built from NameRenderer or NameColumn pick up the placeholder as well; show_metavar=False also applies to them.

A positional parameter's display identifier (the SRC in SRC --src) is no longer part of HelpEntry.names, which now holds -- option names only. It moved to the new HelpEntry.positional_label. Render HelpEntry.display_labels — the positional label followed by every option name — to reproduce the v4 row.

# A custom "names" column renderer.
def names_renderer(entry):
    # v4: return " ".join(entry.names + entry.shorts)
    return " ".join(entry.display_labels)  # v5

The separate HelpEntry.metavar now carries the value placeholder (the PATH in --config PATH); see Help Customization.

Python 3.10 Dropped; toml Extra Removed

Affects: environments on Python 3.10, and installs using cyclopts[toml].

Cyclopts now requires Python >=3.11. Python 3.10 reached end-of-life, and dropping it lets Cyclopts use standard-library tomllib and typing features directly instead of the tomli and typing-extensions backports.

Because TOML config support now uses the built-in tomllib, the toml install extra is no longer needed and has been removed. Replace pip install cyclopts[toml] with pip install cyclopts; TOML config files work out of the box.

cyclopts tree: -d Now Disables Descriptions

Affects: the bundled cyclopts tree CLI tool, not your own apps.

-d was an alias for --description, a no-op since descriptions show by default. It is now equivalent to --no-description.

# my_script.py
from cyclopts import App

app = App(name="myapp")

@app.command
def greet(name: str):
    """Greet someone."""
    print(f"Hello {name}")

app()
$ cyclopts tree my_script.py -d
# v4: myapp
#     └── greet  Greet someone.
# v5: myapp
#     └── greet

Help Metadata Colors Changed; Colors Are Now Themeable

Affects: the appearance of the default help page, not behavior.

The dim styling on the help annotations was hard to read on some terminal color schemes. The [required] annotation is now plain red (was dim red), and the [default: ...], [choices: ...], and [env var: ...] annotations are now gray58, a fixed grey that stays legible across light and dark themes (was dim). Every color in the default help output is also now routed through a named Rich style (cyclopts.usage, cyclopts.border, cyclopts.name, cyclopts.required_marker, cyclopts.required, cyclopts.default, cyclopts.choices, cyclopts.env_var), so you can restyle any of them by defining that key in your Console's Theme. See Help Customization.