Skip to content

v5.0.0

Choose a tag to compare

@BrianPugh BrianPugh released this 23 Sep 23:44
· 24 commits to main since this release

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-case name-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`
  • 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=False
    

    Only placement after the subcommand changed. To reject parent-level parameters placed after a subcommand entirely, use parse_mode="strict".

  • Forwarded *tokens preserve the -- delimiter. A forwarding meta's raw-stream capture parameter (*tokens with allow_leading_hyphen=True) now keeps a user-typed end-of-options delimiter, so the re-parse inside app(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 *args subcommand claims all post-command tokens. A subcommand whose only positional parameter is *args with allow_leading_hyphen=True genuinely 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 (--help still works when only --version is shadowed), and commands taking **kwargs are NOT affected — they keep automatic --help/--version. If a command shadows --help, a --version token in the same invocation triggers version printing (interception runs before binding).

  • cyclopts tree: -d now disables descriptions. Previously -d was 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 with 1, indistinguishable from a command that ran and reported failure (the default result_action maps a False return to exit code 1). Application-level exit codes and 130 for keyboard interrupts are unchanged.

    $ myapp --bogus; echo $?
    # v4: 1
    # v5: 2
  • --version respects the -- delimiter. A --version token after the end-of-options delimiter is positional data, not a version request — matching how --help already behaved.

    $ myapp -- --version
    # v4: printed the app version (command never ran)
    # v5: the command runs; "--version" is bound positionally
  • result_action names are validated eagerly. An invalid action name now raises a descriptive ValueError (listing the valid actions) at App construction, attribute assignment, or invocation-time override — before any command executes. Previously an invalid name was silently accepted and only raised a bare, message-less ValueError at 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_mode is 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 -xvf doesn'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 to None. 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(), including copy(reset_tokens=True) for a copy whose Arguments share metadata but carry fresh empty token lists.

  • Dynamic per-parameter shell completion #917

    • A Parameter.completer callable receives a CompletionContext and 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)]): ...
  • Parameter.metavar and 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.metavar to override the placeholder, or turn it off via the formatter. Closes #885.
    • def main(config: Annotated[Path, Parameter(metavar="FILE")]): ...  # shows "--config FILE"
  • Themeable help colors #916

    • 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], ...).
  • Per-group help panel styling via Group.theme #916

    • A Group can now carry its own theme (a dict or Rich Theme) 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"})
  • App.interactive_shell() improvements #941

    • New intro banner (supports Rich markup; None uses the default banner, "" prints nothing).
    • New history option (True for a default history-file location, or a path) backed by readline.
    • New remap_flags so bare help/version words at the root map to their long flags.
    • quit words and registered commands are handled more robustly, with registered commands and meta commands taking precedence over quit words.
    • readline is now imported lazily.

Fixes

  • fix(help): show CHOICE metavar for choice parameters and collapse variadic-collection metavars to X... by @BrianPugh in #949

Misc

  • RST help now uses rich-rst v2 (has friendly licensing). Dependency pin moved from rich-rst>=1.3.1,<3 to rich-rst>=2.0.1,<3.

Full Changelog: v4.25.3...v5.0.0