Jacob Kaplan-Moss

Today I Learned…

Common Arguments With Typer

I mostly use Typer to write CLIs in Python. I find it mildly easier than Click, but one thing I hadn’t worked out until today was how to implement common options with subcommands. That is, I had code like this::

import typer

cli = typer.Typer()

@cli.command()
def all(pinboard_token: str = typer.Option(None, envvar="PINBOARD_TOKEN")):
    ...

@cli.command()
def new(pinboard_token: str = typer.Option(None, envvar="PINBOARD_TOKEN")):
    ...

if __name__ == '__main___':
    cli()

But I wanted --pinboard-token to be an option on the main command, not on the subcommands. It’s essentially required, so I need to check for it, and didn’t want to do that multiple times. I also want to not repeat myself in each command.

Here’s the solution:

import typer
from types import SimpleNamespace

cli = typer.Typer()

@cli.callback()
def main(
    ctx: typer.Context,
    pinboard_token: str = typer.Option(None, envvar="PINBOARD_TOKEN"),
):
    if not pinboard_token:
        print(
            "Missing pinboard token; pass --pinboard-token or set env[PINBOARD_TOKEN]"
        )
        raise typer.Exit(1)
    ctx.obj = SimpleNamespace(pinboard_token = pinboard_token)


@cli.command()
def all(ctx: typer.Context):
    # do something with ctx.obj.pinboard_token


@cli.command()
def new(ctx: typer.Context):
    # do something with ctx.obj.pinboard_token


if __name__ == "__main__":
    cli()

There are three “tricks” here that I’ve pieced together:

  1. Callbacks are what Typer uses to implement groups or common options. @cli.callback() lets me declare the pinboard_token as a common option, and I can do any error checking (here, just making sure it exists) in the callback. Typer’s documentation suggests saving state (i.e. pinboard_token here) as a global variable. If I wanted to do that I could stop here, but that would make testing a bit more diffuclt, so I want some sort of object to be passed to each subcommand.
  2. Commands (and callbacks) can take a Context by declaring an argument of type typer.Context. This is actually a Click Context, since Typer uses Click under the hood. These contexts have a obj member which can be anything you want: an arbitrary “user data” object.
  3. I could define some special object, but since I just need want a simple namespace with a single member, I used SimpleNamespace. If this object grows and needs more members, I could use dataclasses or attrs or pydantic, but all of those are overkill here.