Invoke

If you’re coming from click or typer, then invoke may likely be how you’ll want to describe your commands.

Note

See Invoke Dependencies for more information about providing context/dependencies to your invoked functions.

You have a few options when choosing how to define the function that will be invoked for your command.

  1. you can simply make your dataclass callable:

    @dataclass
    class Example:
        foo: int
    
        def __call__(self):
            print(self.foo)
    

    This has the benefit of simplicity, and avoids the need to decorate your class. The potential drawback, is that it couples the behavior to the class itself. However being forced to define function at the same location as your CLI definition may or may not be a drawback at all, for your usecase.

  2. You can utilize the explicit invoke=function argument:

    def function(example: Example):
        print(example.foo)
    
    @cappa.command(invoke=function)
    class Example:
        foo: int
    

    This does require you to decorate your class (which you may or may not have already needed to do). However, now function can be defined anywhere else in your codebase, decoupling the CLI definition from the implementation of the command itself.

  3. You can use a module-reference string to target a callable:

    # example.py
    @cappa.command(invoke='foo.bar.function')
    class Example:
        foo: int
    
    # foo/bar.py
    def function(example: Example):
        print(example.foo)
    

    The primary benefit of using string references, especially in large applications is import speed. This style enables you to decouple the definition of a CLI’s shape from the code that is called for a given CLI command. By using this style, the import to e.g. foo.bar.function is deferred until that specific command is selected.

    With this pattern it is possible to avoid all imports to your project’s code at runtime to ensure optimal time-to-helptext and (depending on your project’s structure) potentially avoid importing large swathes of your project on a given CLI invocation.

    Note

    This is equally true in other libraries, and was a significant consideration during cappa’s creation. For example with click, a naive CLI implementation will result in all code that the CLI depends on being imported, even to display help text.

    The equivalent solution for a click application is to inline all imports into the function handlers themselves. This can certainly work, but then can also cause issues when paired with features like Enum-based choices or type arguments.

  4. In simple cases, you can forgo classes entirely in favor of functions.

    import cappa
    
    def cli(foo: int):
        return foo + 1
    
    result = cappa.invoke(cli)
    

    Such a CLI is exactly equivalent to a CLI defined as a dataclass with the function’s arguments as the dataclass’s fields, but with an unnameable class.

    There are various downsides to using functions. Naturally, you lose all ability to reference source classes as dependencies. Subcommands cannot be naturally defined as functions (since there is no type with which to reference the subcommand).

    As such, functions can only be used for certain kinds of CLI interfaces. However, they can reduce the ceremony required to define a given CLI command.

Note

When dealing with nested subcommands, only the “invoke” function for the actually selected command/subcommand will be invoked.

Then, in order to opt into the invoke behavior, you need to call cappa.invoke, rather than cappa.parse.

Implicit invoke imports

The invoke argument can be a direct reference to a function, as evidenced in the single-file documentation examples.

However it can also be a string. When given as a string, it implies a fully qualified module reference to a function. That is, src/package/module/submodule.py with function foo in it, would be given be the string package.module.submodule.foo.

@cappa.command(invoke='package.module.submodule.foo')
class Example:
    ...

This can help to solve an issue that can be somewhat endemic to click applications. Namely, that the act of describing the CLI shape forces the programmer to transitively import all of the code that the CLI might ever reference in any of its subcommands, regardless of whether they’re actually called in the actual command selected.

The usual approach, in click, is to inline imports into the click handler, thus achieving the same thing. With cappa, it’s much the same thing, except for the inversion of control. The dynamic import is performed inside the invoke call, which is mostly just a net reduction in boilerplate.

Invoke Dependencies

cappa.invoke wouldn’t be of much value if all it did was call argument-less functions without any context. Thus, there is a system of “dependencies” that cause arguments to your invoke functions to be supplied on demand, assuming the CLI invocation is capable of fulfilling the dependencies.

There are two kinds of invoke dependencies, implicit and explicit.

Implicit Dependencies

Implicit dependencies are known-unique object instances that the invoke system can produce for your function without an explicit Dep annotation.

These objects include:

  • Any command or subcommand user-defined objects upstream of the selected command/subcommand. For example:

    def foo(command: Example, subcmd: SubExample):
        ...
    
    @dataclass(invoke=foo)
    class SubExample:
        ...
    
    @dataclass
    class Example:
        subcommand: Annotated[SubExample, cappa.Subcommand]
    
    

    Example and SubExample will be available as implicit dependencies.

  • A cappa.Command, which provides the Command object corresponding to the command/subcommand that was parsed.

  • A cappa.Self, which can be used to produce the current command/subcommand.

    Self is primarily useful as a way to reuse generic invoke functions among a number of separate subcommands. For example,

    def invoke(self: Self):
        print(self)
    
    @command(invoke=invoke)
    class Foo: ...
    
    @command(invoke=invoke)
    class Bar: ...
    
  • A cappa.Output, which can be used to produce (themed) stdout/stderr output.

Note

In CLIs with nested subcommand hierarchies, only the subcommand path to the user-selected subcommand will be materialized. That is to say, a subcommand which has not been selected will not be available as an invoke dependency.

It’s therefore programmer error to describe an invoke function which depends on command options that would not have been constructed during parsing, based on where it is in the hierarchy.

Explicit Dependencies

Note

If you’re familiar with FastAPI’s Depends, this will feel very similar.

Custom “explicit dependencies” can be built up, in order to have arbitrary context injected into your invoked functions. Explicit dependencies must be annotated with a Dep in order to be recognized as such.

Note

A function describing an explicit dependency can, itself, depend on other explicit or implicit dependencies. This allows recursively building up a dependency hierarchy.

from typing_extensions import Annotated
from cappa import Dep

# No more dependencies, ends the dependency chain.
def config():
    return {
        'password': os.getenv('DB_PASS'),
    }


# Explicit dependency
def database(config: Annotated[Engine, Dep(config)]):
    return sqlalchemy.create_engine(...)


# Implicit dependency
def logger(example: Example):
    ...


# The actual invoke function, which can depend upon any/all of the above
def invoke_fn(
    example: Example,
    logger: Annotated[Logger, Dep(logger)],
    database: Annotated[Engine, Dep(database)]
):
    ...
  • Any dependency can be referenced by any other dependency, or invoked function.

  • Dependencies are evaluated only once per cappa.invoke call, regardless of how many dependencies transitively depend on it.

  • Dependencies are evaluated on demand, in the order they’re found, meaning they will not be evaluated if the specifically invoked function doesn’t reference it.

Yield Dependencies

Generator functions can be used as implicit context managers. This can be useful for certain kinds of dependencies which require some kind of shutdown/post-action.

from typing_extensions import Annotated
from cappa import Dep
import sqlalchemy

def connection():
    engine = sqlalchemy.create_engine(...)
    with engine.connect() as conn:
        yield conn

def invoke_fn(connection: Annotated[sqlalchemy.Connection, Dep(connection)]):
    ...

As with contextlib.contextmanager, the dependency’s function should execute exactly one yield given one execution through the function. The context of any yield dependencies will remain “entered” up through the action of calling the invoke function.

As such, it is probably not a good idea to return a context-managed resource out of your invoke functions (invoke_fn above)! By the time you receive the value (foo = invoke(...)) it will be exited.

Async Dependencies

Async functions can be supported as dependencies. See asyncio documentation for details.

Global Dependencies

The top-level cappa.invoke command accepts a deps argument which denotes dependency functions which are called unconditionally.

These functions will be invoked before any explicit deps have been evaluated. And given that they’re executed unconditionally, it’s impractical to define one which depends on conditional dependencies like subcommands.

Thus it’s generally only practical to use this to execute code which depends on the top-level command’s implicit dependency. Although it can certainly have no dependencies, you could also just call that function before calling invoke in the first place.

@dataclass
class Command:
    a: int


def dep(command: Command):
    ...


def main():
    cappa.invoke(Command, deps=[dep])

Note

When given as a Sequence (i.e. list, tuple), you can just provide the source function which should act as a Dep, and it will be automatically coerced into a proper Dep.

Note

Individual items (like the dep function above) can also be given as fully qualified string references to the dep, similar to how invoke functions may be provided.

This enables one to fully decouple of the runtime import paths required while defining the CLI from the code required to execute the CLI itself.

Overriding Dependencies

Note

See Testing for additional details. This option is primarily motivated to aid stubbing dependencies for testing.

As an alternative to the above sequence input for deps, you can instead supply a Mapping, where the key is the “source” dependency function (i.e. the function a dependent invoke function would reference) and the value is the actual dependency which should be used in its place.

For example,

# We've decided we want to override "foo"
def foo():
    ...

# and here is a function which depends upon it.
def invocable_function(foo: Annotated[int, Dep(foo)]):
    ...

You can either override the dep with another, “stub” dep by explicitly wrapping the value with a Dep

def stub_dep():
    return 4

cappa.invoke(Command, deps={foo: Dep(stub_dep)})

Or you can directly provide a literal stub value for the dep, by providing the value without a wrapping Dep.

cappa.invoke(Command, deps={foo: 4})

Unfulfilled Dependencies

Should an argument be neither an explicitly annotated Dep, nor typed as one of the available implicit dependencies in the hierarchy, then it’s considered unfulfilled.

If the argument to the callable has a default value, then the argument will simply be omitted, and the function called anyway.

In the event the argument is required, a RuntimeError will be raised and CLI processing will stop.