v5.0.0
Check out the new "Migrating to v5" docs page, covering each intentional breaking change with before/after examples.
All v4 features up to 4.25.3 have been merged into this release.
Breaking
-
Dropped Python 3.10 support (Python 3.10 EoL is Oct 31, 2026) #889
-
Removed fuzzy command-matching. Command names must match exactly.
- It was a temporary v4 backwards-compat shim (#666) for the
PascalCase→pascal-casename-transform change, retrying by stripping dashes/underscores on no exact match. Removed as cleanup for the major release.
@app.command def MyCommand(): ... # registers as "my-command" # v4: `mycommand` fuzzy-matched -> my-command # v5: `mycommand` no longer resolves; use `my-command`
- It was a temporary v4 backwards-compat shim (#666) for the
-
Fallthrough parsing: child wins. Previously a meta app claimed any keyword parameter it recognized regardless of token position — even after a subcommand, and even if the subcommand defined the same name. In v5 (default
parse_mode="fallthrough"), when both levels define the same name, the subcommand wins for tokens placed after it.@app.meta.default def main( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], verbose: Annotated[bool, Parameter(alias="-v")] = False, ): app(tokens) @app.command def greet(name: str, *, version: Annotated[bool, Parameter(alias="-v")] = False): ...
$ myapp greet -v Alice # after the subcommand: CHANGED # v4: meta verbose=True; greet version=False # v5: meta verbose=False; greet version=True (child wins) $ myapp -v greet Alice # before the subcommand: unchanged # v4: meta verbose=True; greet version=False # v5: meta verbose=True; greet version=FalseOnly placement after the subcommand changed. To reject parent-level parameters placed after a subcommand entirely, use
parse_mode="strict". -
Forwarded
*tokenspreserve the--delimiter. A forwarding meta's raw-stream capture parameter (*tokenswithallow_leading_hyphen=True) now keeps a user-typed end-of-options delimiter, so the re-parse insideapp(tokens)still treats the trailing tokens as positional.$ myapp sub -- -x # v4: meta captured ("sub", "-x") -> Error: Unknown option: -x. # v5: meta captured ("sub", "--", "-x") -> inner parse keeps -x positional
Wrappers that manually re-inserted
--as a workaround will now see it twice — remove the workaround. Leaf commands (that don't forward) are unchanged: the delimiter is consumed as a marker and never appears in bound values. -
A greedy
*argssubcommand claims all post-command tokens. A subcommand whose only positional parameter is*argswithallow_leading_hyphen=Truegenuinely claims every token after the command; meta parameters placed after such a subcommand no longer bubble up.@app.meta.default def main( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], user: str = "default", ): app(tokens) @app.command def sub(*args: Annotated[str, Parameter(allow_leading_hyphen=True)]): ...
$ myapp sub --user=alice # v4: meta user=alice; sub args=() # v5: meta user=default; sub args=('--user=alice',)Place meta parameters before the subcommand, or give the subcommand explicit keyword parameters instead of a catch-all.
-
User parameters shadow
--help/--version. A command may now define its own parameter named like an auto-registered help/version flag; the token binds to the user's parameter instead of triggering the handler, and the command's help page lists the user's parameter.@app.command def sub(*, version: bool = False): return version
$ myapp sub --version # v4: printed the app version # v5: binds the version parameter to True
Shadowing is per-flag (
--helpstill works when only--versionis shadowed), and commands taking**kwargsare NOT affected — they keep automatic--help/--version. If a command shadows--help, a--versiontoken in the same invocation triggers version printing (interception runs before binding). -
cyclopts tree:-dnow disables descriptions. Previously-dwas a positive alias for--description— a no-op, since descriptions are on by default. It is now a negative alias (equivalent to--no-description), as originally intended.$ cyclopts tree my_script.py -d # v4: descriptions shown (flag was a no-op) # v5: descriptions hidden
-
Parse errors exit with code 2. CLI usage errors (unknown option, missing argument, coercion failure, ...) now exit with code
2, matching argparse and Click. Previously they exited with1, indistinguishable from a command that ran and reported failure (the defaultresult_actionmaps aFalsereturn to exit code1). Application-level exit codes and130for keyboard interrupts are unchanged.$ myapp --bogus; echo $? # v4: 1 # v5: 2
-
--versionrespects the--delimiter. A--versiontoken after the end-of-options delimiter is positional data, not a version request — matching how--helpalready behaved.$ myapp -- --version # v4: printed the app version (command never ran) # v5: the command runs; "--version" is bound positionally
-
result_actionnames are validated eagerly. An invalid action name now raises a descriptiveValueError(listing the valid actions) atAppconstruction, attribute assignment, or invocation-time override — before any command executes. Previously an invalid name was silently accepted and only raised a bare, message-lessValueErrorat result-handling time, after the command had already run.App(result_action="bogus") # ValueError, raised immediately app(tokens, result_action="bogus") # ValueError, command never runs
Features
-
App.parse_mode— hierarchical parameter scoping between meta apps and subcommands. Two modes:"fallthrough"(default): meta parameters may appear anywhere in the token stream; post-command tokens the subcommand leaves unconsumed bubble up to the meta. When both levels define the same flag, the child wins."strict": parameters bind only at the command level where they appear — Click/Typer-style scoping. A meta parameter placed after a subcommand is rejected with a scope-aware hint (Did you mean to place it directly after "myapp"?), and help pages exclude parent meta parameters at the child level. Shell completion respects both modes.
parse_modeis inherited by subapps unless explicitly overridden. See the new "Parse Mode" docs page.Combined short options are resolved against the MERGED flag namespace of both levels — the person typing
myapp cmd -xvfdoesn't know which level implements each flag, so a single GNU left-to-right scan of the merged table routes each character to its owner (subcommand wins same-letter collisions; the first value-taking option absorbs the remainder or next token as its value; unknown characters are reported by the subcommand).# meta owns -v and -u (value-taking); cmd owns -x and -f: $ myapp cmd -xvf # -x -f -> cmd; -v -> meta $ myapp cmd -xuroot # -x -> cmd; meta user="root" (attached value) $ myapp cmd -xu alice # -x -> cmd; meta user="alice" (next token)
Other cross-scope cases: a meta flag interleaved between a child option and its value binds at the meta while the child pairs its option with the value, and a meta option missing its value reports the real "requires an argument" error instead of a misleading "Unknown option".
-
Variable token-lengths within a
Union. Members that consume a different number of tokens can now coexist. Order matters: the longer (multi-token) member should come first so it gets first claim on the tokens.def main(value: tuple[int, int] | int): ... # --value 5 -> 5 # --value 1 2 -> (1, 2)
On v4 this raises
Cannot Union types that consume different numbers of tokens. -
list[Union[...]]with differing token-lengths. Each element is matched independently against the union members.def main(coords: list[tuple[int, int] | int]): ...
-
null/none(case-insensitive) parse toNone. Applies to optional types. Union ordering decides the result: a member that legitimately accepts the literal string wins first.def main(value: int | None): ... # --value none -> None def main(path: Path | None): ... # --path none -> Path("none") (Path matches first) def main(value: str | None): ... # --value none -> "none" (str matches first) # works inside collections too: list[int | None] "1 none 3" -> [1, None, 3]
-
ArgumentCollection.copy(), includingcopy(reset_tokens=True)for a copy whoseArguments share metadata but carry fresh empty token lists. -
Dynamic per-parameter shell completion #917
- A
Parameter.completercallable receives aCompletionContextand returns context-aware suggestions, so completions can depend on values only known at runtime (git branches, running containers, rows from a database). Works in bash/zsh/fish. -
def complete_user(ctx): return load_users() # e.g. {"alice": "admin", "bob": "member"} -> {value: description} @app.command def promote(user: Annotated[str, Parameter(completer=complete_user)]): ...
- A
-
Parameter.metavarand type-derived value placeholders #919- Keyword-only parameters now show a value placeholder in the help panel (e.g.
--config PATH), separating a value's shape from a positional parameter's display identifier. - Set
Parameter.metavarto override the placeholder, or turn it off via the formatter. Closes #885. -
def main(config: Annotated[Path, Parameter(metavar="FILE")]): ... # shows "--config FILE"
- Keyword-only parameters now show a value placeholder in the help panel (e.g.
-
- Every color in the help output is now a bare
cyclopts.*Rich named style, so you can recolor any part of the help page by supplying your own theme; your overrides win over the built-in defaults. - This includes the panel border (
cyclopts.border), the usage line (cyclopts.usage), parameter/command names (cyclopts.name), and the metadata annotation styles ([default],[choices],[required], ...).
- Every color in the help output is now a bare
-
Per-group help panel styling via
Group.theme#916- A
Groupcan now carry its own theme (adictor RichTheme) to style just that group's panel independently of the rest of the help page. -
danger = Group("Danger Zone", theme={"cyclopts.border": "red", "cyclopts.name": "bright_red"})
- A
-
App.interactive_shell()improvements #941- New
introbanner (supports Rich markup;Noneuses the default banner,""prints nothing). - New
historyoption (Truefor a default history-file location, or a path) backed by readline. - New
remap_flagsso barehelp/versionwords at the root map to their long flags. quitwords and registered commands are handled more robustly, with registered commands and meta commands taking precedence over quit words.- readline is now imported lazily.
- New
Fixes
- fix(help): show
CHOICEmetavar for choice parameters and collapse variadic-collection metavars toX...by @BrianPugh in #949
Misc
- RST help now uses rich-rst v2 (has friendly licensing). Dependency pin moved from
rich-rst>=1.3.1,<3torich-rst>=2.0.1,<3.
Full Changelog: v4.25.3...v5.0.0