Case Studies

These are examples a meant to show a bit more of a stream of consciousness tutorial of sorts of how one might go from an empty python file to a CLI which does a thing, and design decisions or evolutions that happen along the way.

To-Do List

I am designing a CLI which manages a “To-Do List”, a completely original idea that I indend to sell one day. In order to interact said To-Do list, I’m going to design a CLI interface with roughly the following command structure:

todo add 'Buy bananas'
todo remove 'Buy Bananas'
todo complete 'Buy Bananas'

First i’ll start by getting a basic no-op executable running. Dataclasses are the easiest way to define cappa-compatible classes (because they’re built in), so I’ll go with that.

from dataclasses import dataclass

import cappa

@dataclass
class Todo:
    ...


todo = cappa.parse(Todo)
print(todo)
$ python todo.py --help
Usage: todo [-h] [--completion COMPLETION]

  Help
    [-h, --help]               Show this message and exit.
    [--completion COMPLETION]  Use --completion generate to print
                               shell-specific completion source. Valid
                               options: generate, complete.

$ python todo.py
Todo()

Ok great, now let’s add the “add” command

from __future__ import annotations

from dataclasses import dataclass

import cappa

@dataclass
class Todo:
    command: cappa.Subcommands[AddCommand]


@dataclass
class AddCommand:
    todo: str

todo = cappa.parse(Todo)
print(todo)

Added from __future__ import annotations so that the classes could be defined top-down sequentially, which I think makes understanding the flow of the commands more intuitive.

Oh but oops:

$ python todo.py
Usage: todo {add-command} [-h] [--completion COMPLETION]

Error: A command is required: {add-command}

Ah yes, the name of my AddCommand class isn’t the same as what I want the name of the literal CLI command to be. This should fix it:

@cappa.command(name="add")
@dataclass
class AddCommand:
    todo: str
$ python todo.py add 'buy bananas'
Todo(command=AddCommand(todo='buy bananas'))

Now that the CLI has the shape I’d like it to have, time to implement the functionality.

I have a few options for how to attach behavior to a given command/subcommand, but in the interest of conciseness i’ll simply my my dataclass callable.

import json
from pathlib import Path

@cappa.command(name="add")
@dataclass
class AddCommand:
    todo: str

    def __call__(self):
        path = Path("todo.json")

        data = []
        if path.exists():
            data = json.loads(path.read_text())

        data.append(self.todo)
        path.write_text(json.dumps(data))

We’ll also need to replace cappa.parse with cappa.invoke.

Let’s also quickly add a list command to be able to actually test it out.

...
@dataclass
class Todo:
    command: cappa.Subcommands[AddCommand | ListCommand]  # <- gotta remember to
                                                          # include the new command!
...

@cappa.command(name="list")
@dataclass
class ListCommand:
    def __call__(self):
        path = Path("todo.json")

        data = []
        if path.exists():
            data = json.loads(path.read_text())

        for item in data:
            print(f" - {item}")

Now we should be able to test it out:

$ python todo.py add 'buy bananas'
$ python todo.py add 'clean kitchen'
$ python todo.py list
 - buy bananas
 - clean kitchen

That’s about it! You could keep going and add more commands; or implement it with something fancier than a flat file.

Here is the final program.

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

import cappa


@dataclass
class Todo:
    command: cappa.Subcommands[AddCommand | ListCommand]


@cappa.command(name="add")
@dataclass
class AddCommand:
    todo: str

    def __call__(self):
        path = Path("todo.json")

        data = []
        if path.exists():
            data = json.loads(path.read_text())

        data.append(self.todo)
        path.write_text(json.dumps(data))


@cappa.command(name="list")
@dataclass
class ListCommand:
    def __call__(self):
        path = Path("todo.json")

        data = []
        if path.exists():
            data = json.loads(path.read_text())

        for item in data:
            print(f" - {item}")


cappa.invoke(Todo)

Slow Startup

…Context…

Python CLIs can frequently have slow startup, the more imports and import-time …stuff… that happens between the moment python starts up and the CLI parsing logic executes.

This can be really annoying when all you’re executing is cli.py --help, and it’s taking all of a second or more to run!

This is much less of a problem for a library like argparse, whereas it can quickly become a problem with something like click, where you’re forced to import the whole command tree preemptively in order to describe the CLI itself! (Either that or remember to inline all your imports yourself!).

See invoke docs for more detail!

I’ve got this really simply CLI program that’s already taking an abnormally long time to return on a simple --help! What gives!

# cli.py
from __future__ import annotations

from dataclasses import dataclass

import cappa

from .mean import calculate_mean


@dataclass
class Slow:
    command: cappa.Subcommands[MeanCommand]


@cappa.command(name='mean', invoke=calculate_mean)
@dataclass
class MeanCommand:
    numbers: list[int]

cappa.invoke(Mean)


# mean.py
import pandas
from .slow import MeanCommand


def calculate_mean(command: MeanCommand):
    df = pandas.DataFrame(command.numbers)
    print(df.mean()[0])
time slow/cli.py --help
slow.py --help  0.82s user 0.07s system 165% cpu 0.541 total

840ms to do nothing! You know, I recall importing pandas is reasonably slow maybe inline the import?

# mean.py
def calculate_mean(command: MeanCommand):
    import pandas

    df = pandas.DataFrame(command.numbers)
    print(df.mean()[0])
time slow/cli.py --help
slow.py --help  0.20s user 0.07s system 165% cpu 0.541 total

Aha! That’s a fair bit better. Although it’s kind of annoying to have to remember to inline slow imports throughout my program. Instead, lets have cappa defer the import of the actual command for us!

# cli.py
from __future__ import annotations

from dataclasses import dataclass

import cappa


@dataclass
class DeferImport:
    command: cappa.Subcommands[MeanCommand]


@cappa.command(name='mean', invoke='slow.mean.calculate_mean')
@dataclass
class MeanCommand:
    numbers: list[int]

cappa.invoke(DeferImport)


# mean.py
import pandas
from .slow import MeanCommand


def calculate_mean(command: MeanCommand):
    df = pandas.DataFrame(command.numbers)
    print(df.mean()[0])

Now, the CLI interface definition is nicely isolated from the actual implementation. I dont need to import every possible code path in order to execute the CLI in the first place, and we still have a nice fast CLI!

time slow/cli.py --help
slow.py --help  0.20s user 0.07s system 165% cpu 0.541 total