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 |
|
is invoked by scripts/CI that check exit codes |
|
has command functions named |
|
defines a parameter named |
|
still runs on Python 3.10, or installs the |
|
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:
--versionshadowed →--helpstill shows help.--helpshadowed → a--versiontoken still triggers the version handler (interception runs before binding), and the shadowed--helptoken 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.