Skip to content

Subcommand dispatch

docopt hands you a parsed mapping and stops there. Routing git commit to the code that commits is left to you, and it usually grows into an if/elif ladder over the parsed keys.

Dispatch is that missing layer.

Register and run

Register one handler per command path. run then parses argv and calls the handler for the most specific path that matched.

from docopt2 import Dispatch

app = Dispatch(doc)


@app.on("user", "create")
def create(args):
    ...


app.run()

An on() with no command path registers a fallback, used when no more specific path matches.

A complete git-like CLI. The module docstring is the usage message, so Dispatch(__doc__) needs nothing else:

"""A tiny git-like CLI.

Usage:
  app add <path>...
  app commit --message=<msg>
  app push [--force]
  app

Options:
  --message=<msg>  Commit message.
  --force          Force the push.
"""

from docopt2 import Dispatch

app = Dispatch(__doc__)


@app.on("add")
def add(args):
    print(f"adding {args['<path>']}")


@app.on("commit")
def commit(args):
    print(f"committing: {args['--message']!r}")


@app.on("push")
def push(args):
    print("force push" if args["--force"] else "push")


@app.on()
def default(args):
    print("no subcommand given")


if __name__ == "__main__":
    app.run()

Each invocation is parsed once, then routed to the single handler whose command path matched:

$ python app.py add src tests
adding ['src', 'tests']
$ python app.py commit --message="first commit"
committing: 'first commit'
$ python app.py push --force
force push
$ python app.py
no subcommand given

run() returns whatever the handler returns, so its value can serve as the process exit code or be asserted in a test.

Most specific path wins

on("user", "create") matches only when both user and create are truthy in the parsed result, and a longer registered path outranks a shorter one.

So a generic on("user") acts as a group fallback, while on("user", "create") and on("user", "list") handle the leaves:

@app.on("user")
def user(args): ...            # user, with no known leaf

@app.on("user", "create")
def user_create(args): ...     # user create <name>

@app.on("user", "list")
def user_list(args): ...       # user list

Given the argv user create alice, the two-segment handler wins over the one-segment on("user").

Two different ways an argv can go unhandled

  • A usage pattern matches, but no registered path fits it, not even the empty fallback. run() raises DocoptExit carrying error: no handler is registered for the given command.
  • No usage pattern matches at all. docopt itself rejects the argv earlier, with the usual diagnostic.

Options every subcommand shares

A global --verbose (or --config, --dry-run) belongs to no single subcommand.

Where you write it in a usage line does not constrain where the user types it. Options match position-independently, so app --verbose add src and app add src --verbose are the same parse either way.

The choice is only about how you declare it, and it trades repetition against scope.

Declare it per line and each subcommand accepts its own options and no others:

"""Usage:
  app add <path>... [--verbose]
  app commit --message=<msg> [--verbose]
  app push [--force] [--verbose]

Options:
  --verbose        Chatty.
  --message=<msg>  Commit message.
  --force          Force the push.
"""

Here app commit --message=m --force is rejected: the commit line never declared --force.

Or use the [options] shortcut - declare them once under Options: and let every line take the lot:

"""Usage:
  app add [options] <path>...
  app commit [options] --message=<msg>
  app push [options]

Options:
  --verbose        Chatty.
  --message=<msg>  Commit message.
  --force          Force the push.
"""

That is the DRY form, but it is deliberately loose. [options] expands to every option in the section, so app commit --message=m --force now parses, --force and all.

check does not flag it. This is a legitimate shape, not a mistake.

Reach for it when the options really are shared, and for the per-line form when a subcommand must refuse another's flags.

Either way the handler reads the shared option from its own args, like any other key:

@app.on("add")
def add(args):
    if args["--verbose"]:
        ...

Typed per command

Passing schema= to on(...) binds the parsed result to that schema, so each subcommand handler receives its own typed view instead of the raw Arguments mapping.

@app.on("user", "create", schema=CreateArgs)
def create(args: CreateArgs):
    ...

Dispatch always matches on the parsed mapping first, then binds per handler. That is why schema lives on on() and is never forwarded through run().

A schema only needs to declare the fields that handler reads, and keys belonging to other subcommands are ignored. For the git-like CLI, typing just the commit leaf:

from dataclasses import dataclass

from docopt2 import Dispatch

app = Dispatch(__doc__)


@dataclass
class CommitArgs:
    message: str


@app.on("commit", schema=CommitArgs)
def commit(args: CommitArgs):
    print(f"committing: {args.message!r}")
$ python app.py commit --message="first commit"
committing: 'first commit'

The field name comes from the usage key. --message maps to message, <path> to path, and the value is coerced to the declared field type.

See Typed results for the full key-to-field mapping, and the four schema shapes a handler can receive.

Forwarding options

Extra keyword arguments to run(...) pass straight through to docopt, so anything the parser accepts works from dispatch too:

app.run(version="app 2.0")   # --version prints "app 2.0" and exits

suggest, exit_code, version, help_style, config, allow_extra and the rest all forward unchanged.

schema is the one exception, and run() refuses it rather than quietly ignoring it:

TypeError: Dispatch.run() takes no `schema`; register one per handler with `on(..., schema=...)`

Dispatch matches on the parsed mapping first, and binds each handler its own typed view only afterwards. A schema on run() would type the very thing being routed on, so it belongs on on().

See also