Skip to content

Usage linting

A usage message can be wrong in ways docopt accepts in silence. Describe --verbose under Options: and forget to put it on a usage line, and your help text advertises a flag the parser then rejects with unexpected argument. Nothing complains until a user tries it.

check (and the docopt2 check CLI) lints the grammar itself, before any argv is parsed. It never raises and never touches parsing: it reads the Usage:, Options: and Arguments: blocks, and reports what it finds as a list of warnings.

So you can wire it into a test or a CI step, and fail the build on a grammar mistake instead of shipping it.

From code

check(doc) takes the help string and returns a list[Diagnostic], every entry at "warning" level. An empty list means the grammar is clean.

With a schema, check(doc, schema=Args), the list also holds "error" entries for the ways the schema disagrees with the usage. See Checking a schema.

from docopt2 import check

doc = "Usage: prog\n\nOptions:\n  --verbose  Extra logging."
warnings = check(doc)
len(warnings)          # 1
warnings[0].level      # 'warning'
print(warnings[0].render())

Each entry is the same kind of diagnostic the runtime uses for parse errors. warning.render() returns the block as text, and warning.render(color=True) produces the ANSI color shown in the blocks below.

The list is purely informational. check does not mutate doc, and does not change how docopt() later parses it.

Note

A usage message that does not parse (an unbalanced group, say) is reported as a single "error": the one docopt() raises for it at every run, with its caret. Nothing else is looked at in such a message.

What it flags

check applies seven rules. Each catches a construct the parser tolerates but that almost always means the usage message says something you did not intend.

Warning Fires when
unusable option an option in Options: appears in no usage line and no [options] shortcut is present to absorb it
dead default (option) a [default: ...] sits on a value-taking option that every usage pattern requires, so it can never be applied
dead default (argument) a [default: ...] in an Arguments: section sits on an always-required positional
ambiguous variadics two ... positionals share one sequence, leaving the token split between them undefined
redundant alternative two branches of one alternation group are identical, so one can never be reached
empty [options] a usage line uses [options] but no options are described
repeated annotation one option carries [default: ...], [env: ...] or [config: ...] more than once

Every warning is a Diagnostic. The unusable-option case, rendered, looks like this - the caret points at the exact token in your source:

warning: option `--verbose` is declared but never used | | in the options: | --verbose Extra logging. | ^^^^^^^^^ declared here | = help: add `--verbose` to a usage line, or add `[options]` to accept it

The other rules render the same way. Each example below pairs the smallest usage that triggers the rule with the exact warning check returns.

Unusable options

An option described in Options: but named on no usage line can never be set, unless a [options] shortcut is there to accept it. Three ways to fix it:

  • add the option to a usage line
  • add [options], which accepts every described option and suppresses the warning
  • delete the description
check("Usage: prog [options]\n\nOptions:\n  --verbose  Be loud.")   # []

Dead defaults

A [default: ...] only applies when the element is absent, so if every usage pattern requires the element, the default is unreachable.

It fires on options declared in Options::

doc = "Usage: prog --port=<n>\n\nOptions:\n  --port=<n>  Port [default: 80]."
print(check(doc)[0].render(color=True))
warning: dead default on `--port`, which the usage always requires | | in the options: | --port=<n> Port [default: 80]. | ^^^^^^^^^^^^^ never applies | = help: make `--port` optional with `[ ... ]`, or drop the default

and, identically, on positionals documented in an Arguments: section:

doc = "Usage: prog <host>\n\nArguments:\n  <host>  Host name [default: localhost]."
print(check(doc)[0].render(color=True))
warning: dead default on `<host>`, which the usage always requires | | in the arguments: | <host> Host name [default: localhost]. | ^^^^^^^^^^^^^^^^^^^^ never applies | = help: make `<host>` optional with `[<host>]`, or drop the default

The fix is either to wrap the element in [ ... ] so it becomes optional (and the default can fill in), or to drop the default.

Ambiguous variadics

Two variadic positionals in the same sequence have no defined boundary. Given prog a b c, docopt2 cannot know where <a>... stops and <b>... starts.

This warning carries no caret, since the defect is the pair, not one token.

print(check("Usage: prog <a>... <b>...")[0].render(color=True))
warning: ambiguous grammar: two variadic positionals share one sequence = help: the token split between them is undefined; make one non-variadic, or split into branches

It counts each ... over a positional as one unit along a single path. So both of these are fine:

  • (<a> <b>)... is one repeat with a fixed interleaving, not two competing ones.
  • Two variadics in separate | branches never meet on the same path.
check("Usage: prog (<a> <b>)...")        # []
check("Usage: prog (<a>... | <b>...)")   # []

Redundant alternatives

Two identical branches inside one alternation group mean one of them can never match. Usually it is a copy-paste slip or a typo in what was meant to be a distinct branch.

print(check("Usage: prog (add | add)")[0].render(color=True))
warning: redundant alternative: this branch repeats an earlier one = help: one of the identical `|` alternatives can never be reached; remove it or fix the typo

Empty [options]

A usage line uses the [options] shortcut, but there is no Options: section for it to expand, so it matches nothing.

print(check("Usage: prog [options] <f>")[0].render(color=True))
warning: `[options]` accepts nothing - no options are described | | in the usage: | Usage: prog [options] <f> | ^^^^^^^^^ expands to nothing | = help: describe options in an `Options:` section, or remove `[options]`

Repeated annotations

An option carries the same annotation twice. For [env: ...] and [config: ...] the first one is the one read, so a second source silently never applies. It is usually a leftover from an edit.

doc = "Usage: prog [--port=<n>]\n\nOptions:\n  --port=<n>  Port [config: server.port] [config: port]."
print(check(doc)[0].render(color=True))
warning: option `--port` has more than one `[config: ...]` | | in the options: | --port=<n> Port [config: server.port] [config: port]. | ^^^^^^^^^^^^^^ one too many | = help: keep a single `[config: ...]` on `--port`

Checking a schema

A schema is a second description of the same command line, and the two can disagree. Binding raises DocoptLanguageError for such a disagreement, and for some of them only on the argv that reaches it: a field that is not optional binds until the day its option is left out.

check(doc, schema=Args) finds every disagreement before any argv exists:

import dataclasses
from docopt2 import check

@dataclasses.dataclass
class Args:
    out: str
    level: int
    verbose: str

doc = "Usage: prog [--out=<f>] [--level=<n>]... [--verbose]"
for finding in check(doc, schema=Args):
    print(finding.render())
error: usage element `--out` may be absent, but field `out` is not optional | | in the usage: | Usage: prog [--out=<f>] [--level=<n>]... [--verbose] | ^^^^^^^^^ may be absent | = help: annotate it as `out: ... | None` or give it a default error: field `level` does not fit usage element `--level` | | in the usage: | Usage: prog [--out=<f>] [--level=<n>]... [--verbose] | ^^^^^^^^^^^ typed as int | = note: a repeated usage element yields a list, but the schema type int is not a list; use list[...] error: field `verbose` does not fit usage element `--verbose` | | in the usage: | Usage: prog [--out=<f>] [--level=<n>]... [--verbose] | ^^^^^^^^^ typed as str | = note: a str field maps to a text, but the usage element yields the flag False; use bool or int

These findings are at "error" level, after the warnings about the usage itself. There are four kinds:

Finding Fires when
no matching element a field names no element of the usage
two elements, one field --name and <name> both map to the field name
may be absent an element can be left out, and its field is neither T \| None nor given a default
does not fit the type cannot hold what the element yields: a list for an element that is not repeated, a bool for an option that takes a value, a str for a flag

Two more are about the schema as a whole: annotations that do not resolve, and a plain class with no constructor to take its fields.

A flag yields True, and False unless the usage always requires it. A type that takes neither is reported, and so is one that is handed a value of another type. A type that takes one of the two is a limit the author set, and is not reported.

A counted flag typed with a closed set, Literal[0, 1, 2] on [-v]..., is judged by its shape alone. The set is the author's limit on the count: a fourth -v is refused at run time, as a value of the user's. Which counts a usage can reach is not worked out, so Literal[1] on -v -v is not found.

Fields are held against elements, and no more than that. A default is not held against its own annotation, which is a type checker's to judge. The schema is not constructed, so what its own __init__ or __post_init__ takes or refuses is not looked at. To judge a type the check calls the same coercion that binding uses, so an Enum of the schema is called with the values that are tried.

The schema is held against the whole usage, as docopt(doc, schema=...) binds it. A handler of a Dispatch table binds its schema only on the lines it is run for, and Dispatch.check() holds each schema of a table against those lines.

The check covers a dataclass, a TypedDict and a Cli subclass. A pydantic model binds by rules of its own, so it gets a single "warning" that says it was not checked. So does a schema whose usage message does not parse.

From the command line

docopt2 check <source> runs the same lint over a usage message read from <source>, which is one of:

  • a .py file - its module docstring is read (without importing the module),
  • a text file of raw usage,
  • - for standard input.

Warnings are written to standard error. The command exits 0 when the grammar is clean and 1 when anything was reported, a usage message that does not parse included, so it drops into a pre-commit hook or a CI job as a gate:

$ docopt2 check cli.py warning: dead default on `--name`, which the usage always requires | | in the options: | --name=<who> Who to greet [default: world]. | ^^^^^^^^^^^^^^^^ never applies | = help: make `--name` optional with `[ ... ]`, or drop the default warning: option `--shout` is declared but never used | | in the options: | --shout Upper-case the greeting. | ^^^^^^^ declared here | = help: add `--shout` to a usage line, or add `[options]` to accept it

The cli.py above is the module whose docstring is:

Usage: greet --name=<who>

Options:
  --name=<who>  Who to greet [default: world].
  --shout       Upper-case the greeting.

Tip

Because a warning sets exit status 1, docopt2 check src/app.py in CI fails the build on a grammar defect - the same guarantee the check function gives a test, without importing your program.

See also