Cappa: Declarative CLI Argument Parser

repository·main·Indexed 42 days ago

https://github.com/dancardin/cappa

A declarative command line parsing library for Python that uses type hints to automatically infer CLI argument structures, help text, and completion logic. Cappa supports multiple declaration styles including classes, functions, and methods, and offers two primary execution modes: `parse` for returning typed data structures and `invoke` for dispatching logic to functions. Version 0.33.0.

Tokens
36.2K
Snippets
121
Records
154
Agent score
49%

What's inside Cappa

  1. Overview of Cappa CLI patterns

    main

    Cappa is a declarative command line parsing library that uses runtime type inspection to infer CLI argument behavior, generate help text, and provide dynamic completion. It offers four primary styles of CLI declaration:

    1. Classes: Fields in a class correspond to CLI arguments or subcommands.
    2. Functions: Arguments in a function correspond to CLI arguments.
    3. Methods: Class fields correspond to parent arguments, while methods act as subcommands.
    4. Imperative Construction: Manually building the CLI structure using cappa.Command instead of relying on inference.

    Cappa supports two execution modes:

    • parse: Returns an instance of the input structure (similar to argparse).
    • invoke: Calls a function based on the selected subcommand (similar to click or typer).
  2. Implement or wrap a custom backend

    main

    The backend argument in cappa.invoke and cappa.parse can technically be used to provide a custom backend implementation (e.g., for click). This allows you to retain Cappa's pre-parsing, inference, and dependency injection infrastructure while using a different underlying parser.

    Warning: The backend interface is not yet stabilized. The input/output shapes of a backend may change in future versions. It is recommended to discuss custom backend requirements with the maintainers before relying on specific implementation details.

  3. How Cappa parser backends work

    main

    Cappa separates its architecture into two distinct parts:

    1. Frontend: The primary public API, including annotation inference, the invoke/dependency system, and object/function definitions.
    2. Backend: The component responsible for taking the object graph produced by your CLI definition and parsing the raw CLI input into a format the frontend understands. The backend also controls the help-text formatter.

    While the frontend handles the logic and dependency injection, the backend handles the actual mechanics of argument parsing and help generation.

  4. Understand how Cappa uses annotations

    main

    Cappa uses Python type annotations to perform two primary functions:

    1. Arg-level inference: Automatically determining parsing behavior (like whether an argument is a flag, a positional argument, or an option) based on the type.
    2. Mapping inference: Automatically converting raw string inputs from the CLI into the specified Python types (e.g., converting a string to an int, datetime, or pathlib.Path).

    If the automatic inference does not meet your needs, you can override it by providing a custom parse function within an Arg() annotation.

  5. Use cappa.State to share data during parsing

    main

    A cappa.State object acts as a thin wrapper around a dictionary used to share data across different parts of the Cappa parsing process. You can provide a State instance to cappa.parse or cappa.invoke via the state= argument. If you do not provide one, Cappa will automatically construct an empty one.

    To maintain type safety, State is generic and can be annotated with a TypedDict or a class to define the expected structure of the underlying dictionary.

    import cappa
    from typing import Any, Annotated
    from dataclasses import dataclass
    
    # Define the structure of your state
    class CliState:
        config: dict[str, Any]
    
    # Use the state in a dependency injection context
    def get_config(key: str, state: cappa.State[CliState]):
        return state.state["config"][key]
    
    @dataclass
    class Example:
        # Access state via ValueFrom
        token: Annotated[str, cappa.Arg(default=cappa.ValueFrom(get_config, key="token"))]
    
    config = {"token": "secret-value"}
    state = cappa.State[CliState]("config": config)
    cappa.parse(Example, state=state)
  6. Cappa vs Pydantic-based CLI options

    main

    While libraries like Pydantic-CLI or Pydantic-Settings focus on translating CLI arguments into a Pydantic model instance, Cappa is designed to describe an arbitrary CLI shape using types and models.

    Key differences include:

    • Shape Control: Cappa can describe complex, nested CLI structures (subcommands, specific argument groupings) that Pydantic-based tools often flatten or limit to single-level commands.
    • Type Inference: Cappa provides comprehensive type inference for complex types (e.g., foo: tuple[int, str] | None) to translate them into logical CLI argument shapes, whereas Pydantic-based tools may rely on JSON-style input parsing for complex types.
  7. Why use Cappa instead of Click or Typer

    main

    Cappa is designed to solve several architectural issues found in Click and Typer:

    • Function Integrity: Unlike Click, which wraps functions and changes their behavior (requiring click.invoke or ClickRunner for testing), Cappa inverts this relationship. You do not wrap your functions; they remain plain Python functions that can be called normally in tests or other code. Decorated classes return the actual class instance (e.g., a dataclass or Pydantic model).
    • Declarative CLI Shape: Instead of imperatively building a CLI through a series of command/group decorations, Cappa centrally declares the CLI shape based on types. This makes it easy to determine the total set of arguments and subcommands by looking at the model.
    • Decoupled Imports: Cappa allows you to define the CLI shape without forcing transitive imports of your entire codebase, which prevents slow CLI startup times common in large Click projects.
    • Dependency Injection: Instead of using a blunt pass_context mechanism, Cappa uses a dependency resolution system (similar to FastAPI's Depends) to provide the results of arbitrary functions (like database connections or config loaders) to your commands.
    • Parsing vs. Invocation: Cappa provides separate parse and invoke functions, allowing you to either just get the parsed data or execute the command directly.
  8. How subcommands work in Cappa

    main

    Any supported dataclass-like class used as a top-level command can also function as a subcommand. Subcommands are organized into a tree by having a parent command define which subcommands are available using the Subcommands type hint.

    To implement subcommands, define a field in your parent command with the type Subcommands[UnionOfSubcommands].

    from cappa import Subcommands
    from dataclasses import dataclass
    from typing import Union
    
    @dataclass
    class SubcmdOne:
        name: str
    
    @dataclass
    class SubcmdTwo:
        name: str
    
    @dataclass
    class Command:
        subcommand: Subcommands[Union[SubcmdOne, SubcmdTwo]]
  9. Chain multiple default fallbacks using `cappa.Default`

    main

    The cappa.Default construct allows you to specify an ordered chain of fallback options. You can chain these using the | operator. If a handler returns cappa.Empty, cappa moves to the next item in the chain.

    Common Fallback Patterns:

    • Static Fallback: Arg(default=Default(Env("FOO"), default=4)) (Try env var FOO, then fallback to 4).
    • Interactive Fallback: Arg(default=Env("FOO") | Prompt("Enter value")) (Try env var FOO, then prompt the user).

    Note: Default.default (the static fallback) does not invoke the Arg.parse parser.

    # Chain: Env Var -> User Prompt -> Static Value
    foo: Annotated[int, Arg(default=Env("FOO") | Prompt("Gimme") | 4)]
  10. Implement subcommands using `cappa.Subcommands`

    main

    To create a CLI with subcommands, define a field in your main dataclass using cappa.Subcommands[T], where T is a Union of the command dataclasses.

    Use the @cappa.command(name="...") decorator on your command dataclasses to specify the literal string used in the CLI (e.g., todo add instead of the class name AddCommand).

    from __future__ import annotations
    from dataclasses import dataclass
    import cappa
    
    @dataclass
    class Todo:
        command: cappa.Subcommands[AddCommand | ListCommand]
    
    @cappa.command(name="add")
    @dataclass
    class AddCommand:
        todo: str
    
    @cappa.command(name="list")
    @dataclass
    class ListCommand:
        ...
    
    cappa.invoke(Todo)
  11. Choose between cappa.parse and cappa.invoke

    main

    Cappa provides two primary ways to interface with your CLI definitions, depending on whether you want an argparse-style or a click-style workflow:

    1. cappa.parse (argparse-style): Use this if you want the library to simply parse command-line arguments and return a populated instance of your dataclass. You are responsible for calling your logic manually using the returned object.
    2. cappa.invoke (click-style): Use this if you want the library to handle the execution flow. By using the @cappa.command(invoke=...) decorator on your class, cappa.invoke will automatically dispatch the parsed arguments to your target function.
  12. Implement subcommands in Cappa

    main

    A subcommand is a regular class that behaves like a cappa.Command. To attach subcommands to a parent command, you must annotate the field in the parent class that captures the subcommand options with cappa.Subcommand or use the cappa.Subcommands shorthand.

    Ways to annotate subcommands:

    1. cappa.Subcommands[UnionType]: The preferred shorthand when you don't need to customize Subcommand constructor arguments.
    2. Annotated[UnionType, cappa.Subcommand]: Used when you need to pass arguments to the Subcommand constructor.
    3. Annotated[UnionType, cappa.Subcommand()]: Explicitly instantiating the Subcommand object.

    Key behaviors:

    • Naming: By default, the CLI name is the class name converted to dash-case (e.g., SubcmdOne becomes subcmd-one). To override this, use @cappa.command(name="the-name") on the subcommand class.
    • Optional Subcommands: To make a subcommand optional, include None in the type union and provide a default value of None for the field.
    • Required Subcommands: A union of subcommand classes without None results in a required subcommand.
    from __future__ import annotations
    from dataclasses import dataclass
    from typing import Annotated, Union
    from cappa import Subcommand, Subcommands
    
    @dataclass
    class SubcmdOne:
        example: str
    
    @dataclass
    class SubcmdTwo:
        option: int
    
    @dataclass
    class Command:
        # Using the Subcommands shorthand
        subcommand: Subcommands[SubcmdOne | SubcmdTwo]
    
        # Using Annotated with Subcommand
        subcommand2: Annotated[SubcmdOne | SubcmdTwo, Subcommand]
    
        # Using Annotated with an instantiated Subcommand
        subcommand3: Annotated[SubcmdOne | SubcmdTwo, Subcommand()]
    
        # Making it optional
        subcommand_optional: Subcommands[SubcmdOne | SubcmdTwo | None] = None