Fact: I haven’t published in a while. I won’t bore you with excuses. Instead, I’m going to do a quick blurb on something I learned about today: typer.Context.

I’ve been using typer indirectly for the past few years mostly via spacy’s config and scripts. More recently, I built a package at work that required scripts to be run from the terminal. You could consider this my “baptism by fire”, except it wasn’t really painful until today. Here’s the setup.

[!NOTE] Install these dependencies if you want to follow along.

$ pip install networkx
$ pip install typer
"""Contents of main.py."""

import networkx as nx
from networkx.classes.digraph import DiGraph
import typer

# Create a simple path graph with 5 nodes.
digraph: DiGraph = nx.path_graph(n=5, create_using=nx.DiGraph)

# Define the app.
app = typer.Typer()


# Add a command.
@app.command()
def get_descendants(node: int) -> None:
    """Return all descendant nodes of ``node``."""
    print(nx.descendants(G=digraph, source=node))


if __name__ == "__main__":
    app()

When I call this script, it creates a typer app with a default command, get_descendants. If called without any arguments, it will error. To know what arguments to provide, run the following:

$ python main.py --help
                                                                                                                                                                  
 Usage: main.py [OPTIONS] NODE                                                                                                                                    

 Return all descendant nodes of ``node``.

╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *    node      TEXT  [default: None] [required]                                                                                                                │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --install-completion          Install completion for the current shell.                                                                                        │
│ --show-completion             Show completion for the current shell, to copy it or customize the installation.                                                 │
│ --help                        Show this message and exit.                                                                                                      │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

That’s pretty fancy! We get the commands docstring, accepted arguments, and a list of options. One of the many reasons I enjoy working with typer. 😉 You can read more about why they’re showing up, here. Moving on. Let’s see what the output looks like if we provide an argument.

$ python main.py 3 
{4}

Exactly what we’d expect. Confused? Check out the docs for networkx.descendants.

This seems pretty easy; where’s the fire? 🔥 Right here.

"""Contents of pkg.py"""

import networkx as nx
from networkx.classes.digraph import DiGraph

# Moved the digraph here.
digraph: DiGraph = nx.path_graph(n=5, create_using=nx.DiGraph)

"""Contents of main.py."""

import networkx as nx
import typer

from pkg import digraph  # This is new!

# Define the app.
app = typer.Typer()


# Add a command.
@app.command()
def get_descendants(node: int) -> None:
    """Return all descendant nodes of ``node``."""
    print(nx.descendants(G=digraph, source=node))


if __name__ == "__main__":
    app()

See how I imported a digraph instead of defining one in the script? Running this doesn’t raise any issues, but testing it? That gave my brain a good racking.

My first idea was to supply the digraph as an argument. That would allow me to use a (very large) graph in production, and a (very small) graph for testing. EH! Wrong answer. typer is for the CLI, which means the arguments have to be parsable from the CLI. Strings, numbers, and other text characters work just fine, but graphs? No dice.

How do I swap out the graph in a non-complicated way? Enter typer.Context (and callback if we’re being fully transparent).

"""Contents of main.py."""

from types import SimpleNamespace

import networkx as nx
from networkx.classes.digraph import DiGraph
import typer

from pkg import digraph

# Define the app.
app = typer.Typer()


@app.callback()
def graph_callback(ctx: typer.Context) -> None:
    """Attaching the digraph to the context."""
    ctx.obj = SimpleNamespace(digraph=digraph)


# Add a command.
@app.command()
def get_descendants(ctx: typer.Context, node: int) -> None:
    """Return all descendant nodes of ``node``."""
    digraph: DiGraph = ctx.obj.digraph
    print(nx.descendants(G=digraph, source=node))


if __name__ == "__main__":
    app()

I’ve introduced a new function called graph_callback, decorated it with @app.callback(), and added a single argument, ctx. Before going further, let’s run the following command:

$ python main.py --help
                                                                                                                                                                  
 Usage: main.py [OPTIONS] COMMAND [ARGS]...                                                                                                                       
                                                                                                                                                                  
 Attaching the digraph to the context.

╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --install-completion          Install completion for the current shell.                                                                                        │
│ --show-completion             Show completion for the current shell, to copy it or customize the installation.                                                 │
│ --help                        Show this message and exit.                                                                                                      │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ get-descendants   Return all descendant nodes of ``node``.                                                                                                     │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

It’s different! We’re now presented with a new Commands section. Also note that the docstring for the callback graph_callback appears. The reason is that it’s the first “command” assigned to the app. If you want something different, you can assign some custom text to the typer.Typer argument, help. But for now all I care about is the get-descendants command. Let’s run the next command.

$ python main.py get-descendants --help
                                                                                                                                                                  
 Usage: main.py get-descendants [OPTIONS] NODE                                                                                                                    

 Return all descendant nodes of ``node``.

╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *    node      INTEGER  [default: None] [required]                                                                                                             │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                                                                                    │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

This looks similar to what we originally had. Did you notice that we don’t see a ctx argument? That’s because it’s a special typer object. I won’t get into the details in this post, just know that because it doesn’t show up, that means we don’t have to supply it. But rest assured, it’s still there, and we can use it within the command.

Now what’s this @app.callback() thing? That is where we provide the digraph to the Context. Order is important here—if we don’t define the @app.callback() before our @app.command(), we won’t have access to the digraph. Remember that. Oh, and don’t forget the parentheses.

When we run the script,

  1. typer initializes the app,
  2. then the @app.callback which adds the digraph to the Context,
  3. then the @app.command which now has access to the same Context used in the @app.callback.

Which means running the command below will return the same results we got originally.

$ python main.py get-descendants 3
{4}

Why are we doing this again? Oh right, testing. Not only does the Context allow us to sneak a new object into our get_descendants function, it can also be overwritten.

"""Contents of test_main.py"""

from types import SimpleNamespace

import networkx as nx
from networkx.classes.digraph import DiGraph
import typer
from typer.testing import CliRunner

from main import app


def make_test_graph(ctx: typer.Context) -> None:
    """Make a test graph with two nodes and one edge."""
    digraph: DiGraph = nx.path_graph(n=2, create_using=nx.DiGraph)
    ctx.obj = SimpleNamespace(digraph=digraph)


# This will run our code as if it came from the command line/terminal.
runner = CliRunner()


def test_get_descendants() -> None:
    app.callback()(make_test_graph)  # Override the digraph here.
    command = "get-descendants"
    args = [command, "0"]
    result = runner.invoke(app=app, args=args)
    assert "{1}" in result.stdout

With a little helper function to update the Context and one line to override the @app.callback, we get a passing test. 😎 Hopefully this helps someone! ✌️

Resources That Helped Me