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()raisesDocoptExitcarryingerror: no handler is registered for the given command. - No usage pattern matches at all.
docoptitself 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:
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.
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}")
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.
Completing values¶
A table answers a shell's completion request itself, and the schemas of its handlers say which values there are. Take two commands that share an option:
from dataclasses import dataclass
from typing import Literal
from docopt2 import Dispatch
deploy_doc = """Usage:
app deploy <env> [--format=<f>]
app status [--format=<f>]
Options:
--format=<f> Output format.
"""
tool = Dispatch(deploy_doc)
@dataclass
class DeployArgs:
env: Literal["dev", "prod"]
format: Literal["json", "yaml"] | None
@dataclass
class StatusArgs:
format: Literal["yaml", "table"] | None
@tool.on("deploy", schema=DeployArgs)
def deploy(args: DeployArgs): ...
@tool.on("status", schema=StatusArgs)
def status(args: StatusArgs): ...
$ app deploy <TAB>
--format --format=json --format=yaml dev prod
$ app deploy dev --format <TAB>
json yaml
$ app status --format <TAB>
table yaml
The typed words leave some handlers in reach: those that can still be the one run once the line is whole.
After deploy that is the deploy handler alone, so its schema completes the line. json is its format
and table the status handler's, and --format completes each for its own command. Its schema reads the
words already typed too: after app deploy nowhere nothing is offered.
While several handlers are in reach, the value of an option is offered only where each of them that
declares the field takes it: before any command, app --format <TAB> offers yaml. A positional is
completed by the handler that is run for the line as it would be finished. On a line that two commands
share, app (deploy | status) <env>, each command is offered the environments of its own handler.
A value typed for an option narrows what comes next. A command or an option is left out where every
handler it would leave in reach refuses that value: after app --format json the next word offered is
deploy and not status, whose handler refuses json. See
Completing values.
Checking the table¶
check(doc, schema=...) holds a schema against the whole usage, as
docopt(doc, schema=...) binds it. A handler binds its schema only on the lines it is run for, so held
that way an element that those lines always give is reported as one that may be absent:
from docopt2 import check
[found.summary for found in check(deploy_doc, schema=DeployArgs)]
# ['usage element `<env>` may be absent, but field `env` is not optional']
tool.check() holds each schema against the lines of its own handler, and has nothing to report here:
A usage line is held whole. Where one line serves two commands, app (add <name> | list), <name> is
reported as one that may be absent for the handler of add too: give such commands lines of their own.
A finding names its handler. A handler that no argv can reach is reported as well, a mistyped command among them:
typo = Dispatch(deploy_doc)
@typo.on("staus")
def mistyped(args): ...
[found.summary for found in typo.check()]
# ['handler `staus` can never run: the usage has no `staus`']
Forwarding options¶
Extra keyword arguments to run(...) pass straight through to docopt, so
anything the parser accepts works from dispatch too:
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:
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¶
Dispatchin the API reference.- Typed results for the schema shapes a handler can receive.