pypi cyclopts 5.0.0b1
v5.0.0b1

latest releases: 5.3.2, 5.3.1, 5.3.0...
3 months ago

Check out the new "Migrating to v5" docs page, covering each intentional breaking change with before/after examples.

Breaking

  • 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.

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.

Don't miss a new cyclopts release

NewReleases is sending notifications on new releases.