cyclopts

repository·main·Indexed 22 days ago

https://github.com/brianpugh/cyclopts

A modern Python CLI framework that uses advanced type hints (including Pydantic, Dataclasses, and Attrs) to automatically generate rich help pages and perform robust parameter validation. It provides tools for managing complex applications via the App class, simple execution with run(), and specialized utilities like StdioPath for handling stdin/stdout as filesystem paths.

Tokens
73.5K
Snippets
184
Records
309
Agent score
78%

What's inside cyclopts

  1. Use enum.Flag for boolean flag collections

    main

    enum.Flag (and enum.IntFlag) are treated as collections of boolean flags. You can provide multiple flags via the CLI to combine them.

    Behavior:

    • Case-insensitive name lookup.
    • Underscores in names become hyphens in the CLI.
    • You can pass multiple flags as positional arguments or as specific options (e.g., --permissions.write).

    To expose flags directly as individual booleans (e.g., --read instead of --permissions.read), use Namespace Flattening.

    from cyclopts import App
    from enum import Flag, auto
    
    app = App()
    
    class Permission(Flag):
        READ = auto()
        WRITE = auto()
        EXECUTE = auto()
    
    @app.default
    def default(permissions: Permission = Permission.READ):
        print(f"Permissions: {permissions}")
    
    app()
  2. Type coercion rules for Bool

    main

    Boolean coercion depends on how the parameter is used:

    1. As a Keyword (Flag): Booleans act as flags that take no parameters.
      • --my-flag sets the value to True.
      • --no-my-flag sets the value to False (controlled by Parameter.negative).
    2. As a Positional Argument: Cyclopts performs a case-insensitive lookup against specific allowed values. It is stricter than standard Python bool() casting.
      • True-like: "yes", "y", "1", "true", "t".
      • False-like: "no", "n", "0", "false", "f".
      • Any other value (including 2) will raise a CoercionError.
    3. As a Keyword with assignment (--flag=value): Uses the same positional argument rules as above.
    from cyclopts import App
    
    app = App()
    
    @app.command
    def foo(my_flag: bool):
        print(my_flag)
    
    app()
  3. Use user-defined classes as parameters

    main

    Cyclopts supports using classically defined user classes and several dataclass-like libraries as command parameters. This allows you to group related arguments into a single object. Supported libraries include:

    • attrs
    • dataclass
    • NamedTuple
    • pydantic (Note: For pydantic classes, Cyclopts relies on Pydantic's own coercion engine rather than performing internal conversions)
    • TypedDict

    When using these classes, Cyclopts enables subkey parsing via dot-notation (e.g., --user.name) or positional assignment.

    from cyclopts import App
    from dataclasses import dataclass
    from typing import Literal
    
    app = App()
    
    @dataclass
    class User:
       name: str
       age: int
       region: Literal["us", "ca"] = "us"
    
    @app.default
    def main(user: User):
       print(user)
    
    app()
  4. Compare Typer and Cyclopts Type-Hint Handling

    main

    While Cyclopts and Typer handle most type-hints similarly, there are key differences in how they process specific types:

    • enum.Enum: Cyclopts performs lookups in the reverse direction compared to Typer. For terse choice options, typing.Literal is often preferred in Cyclopts.
    • typing.Union: Unlike Typer, Cyclopts supports type unions.
  5. Cross-reference commands in Sphinx

    main

    The Sphinx directive automatically generates RST reference labels for all commands. This allows you to link to specific commands from anywhere in your documentation using the :ref: role.

    The anchor format is cyclopts-{app-name}-{command-path}.

    Examples:

    • Root application myapp $\rightarrow$ cyclopts-myapp
    • Subcommand deploy $\rightarrow$ cyclopts-myapp-deploy
    • Nested subcommand production under deploy $\rightarrow$ cyclopts-myapp-deploy-production
    See :ref:`cyclopts-myapp-deploy` for deployment options.
  6. Combine multiple configuration sources

    main

    You can pass a list of configuration objects to App.config to combine multiple sources. Configurations are applied sequentially, meaning later sources in the list can override values from earlier ones.

    Resolution order (from highest to lowest priority):

    1. CLI arguments (always override everything)
    2. Later config sources in the App.config list
    3. Earlier config sources in the App.config list
    4. Python default values defined in the function signature

    Example: To allow environment variables to override TOML settings, place Env before Toml in the list:

    app = cyclopts.App(
        name="character-counter",
        config=[
            cyclopts.config.Env("CHAR_COUNTER_"),
            cyclopts.config.Toml(
                "pyproject.toml",
                root_keys=["tool", "character-counter"],
                search_parents=True,
            ),
        ],
    )
    import cyclopts
    from pathlib import Path
    
    app = cyclopts.App(
        name="character-counter",
        config=[
            # Since Env comes before Toml, it has priority in the override chain
            cyclopts.config.Env("CHAR_COUNTER_"),
            cyclopts.config.Toml(
                "pyproject.toml",
                root_keys=["tool", "character-counter"],
                search_parents=True,
            ),
        ],
    )
    
    @app.command
    def count(filename: Path, *, character="-"):
        print(filename.read_text().count(character))
    
    if __name__ == "__main__":
        app()
  7. Support both positional and keyword arguments in Cyclopts

    main
    Unlike Typer, which restricts a parameter from being both positional and keyword, Cyclopts allows command parameters to be specified either as positional arguments or as named keyword options. This is useful for implementing commands like mv where you might want to provide arguments directly (e.g., mv foo bar) or explicitly via flags (e.g., mv --src foo --dst bar).
  8. Use the __cyclopts_returncode__ protocol for custom exit codes

    main

    You can allow returned objects to define their own exit codes by implementing a __cyclopts_returncode__ method. When Cyclopts encounters an object with this method, it will use the method's return value (which must be an int) as the process exit code instead of the default behavior. This is useful for returning rich objects that handle their own presentation (e.g., via __rich__) while still signaling success or failure to the shell.

    from cyclopts import App
    
    class HealthCheck:
        def __init__(self, service: str, healthy: bool):
            self.service = service
            self.healthy = healthy
    
        def __rich__(self) -> str:
            status = "[green]OK[/green]" if self.healthy else "[red]FAIL[/red]"
            return f"{self.service}: {status}"
    
        def __cyclopts_returncode__(self) -> int:
            return 0 if self.healthy else 1
    
    app = App()
    
    @app.command
    def check(service: str) -> HealthCheck:
        """Check the health of a service."""
        return HealthCheck(service, healthy=True) # Example usage
    
    app()
  9. Inherit or Override Help Prologue and Epilogue

    main

    Help prologues and epilogues follow an inheritance model. When you register a child App as a command of a parent App, the child inherits the parent's help_prologue and help_epilogue settings.

    • Inheritance: If a child App does not define its own prologue or epilogue, it uses the parent's.
    • Overriding: If a child App defines its own help_prologue or help_epilogue, it overrides the parent's value for that specific command.
    • Disabling: To remove an inherited prologue or epilogue for a specific subcommand, set the attribute to an empty string ("").
    parent = App(
        name="myapp",
        help_epilogue="Version 1.0.0 | support@example.com"
    )
    
    # Child inherits parent's epilogue
    child = App(name="process", help="Process data files.")
    parent.command(child)
    
    # Another child overrides with its own epilogue
    admin = App(
        name="admin",
        help="Admin commands",
        help_epilogue="Admin Tools v2.0 | USE WITH CAUTION"
    )
    parent.command(admin)
    
    # To disable inherited epilogue for a specific command
    no_epilogue = App(name="internal", help_epilogue="")
    parent.command(no_epilogue)
    
    parent()
  10. How Cyclopts differs from Typer

    main

    Cyclopts is designed to address specific limitations found in the Typer library. Key differences include:

    • Function Independence: Unlike older versions of Typer that relied on proxy default values (making decorated functions difficult to use without Typer), Cyclopts leverages modern Python type hints (like Annotated) to ensure decorated command functions remain easy to use as standard Python functions.
    • Parsing Control: While Typer is built on top of Click, which can create ambiguity between Typer-specific features and Click-specific features, Cyclopts uses its own internal parsing strategy. This provides complete control over the parsing process and avoids the overhead or confusion of the Click dependency.
  11. Distinguish between Python function names and CLI command names

    main

    When using lazy loading, the import path must point to the real Python identifier. You can use the name parameter to decouple the CLI command name from the Python function name.

    Example mapping:

    • Python function list_users $\rightarrow$ CLI command list via name="list".
    • Python function delete $\rightarrow$ CLI command remove via name="remove".
    from cyclopts import App
    
    user_app = App(name="user")
    
    # Function name: "list_users"
    # CLI command name: "list"
    user_app.command("myapp.commands.users:list_users", name="list")
    
    # Function name: "delete"
    # CLI command name: "remove"
    user_app.command("myapp.commands.users:delete", name="remove")
  12. Coercion rules for Union and Optional types

    main

    When using Union or Optional types, Cyclopts attempts to coerce the input by iterating through the types in the union left-to-right. It stops at the first type that successfully parses the input.

    • None type hints are ignored during this process.
    • Optional[T] is treated as Union[T, None].