Skip to content

Typed results

Pass a schema to docopt and the parsed result comes back as a typed object, with values coerced to the field types, instead of an Arguments mapping of strings:

import dataclasses
from docopt2 import docopt

@dataclasses.dataclass
class Args:
    host: str
    port: int

doc = "Usage: prog <host> <port>"
docopt(doc, "127.0.0.1 8080", schema=Args, complete=False)
# Args(host='127.0.0.1', port=8080)

That port is an int, not the string the command line carried.

The schema is the single source of truth for both the runtime values and the static type. A type checker sees Args, not dict[str, Any], so result.port is a known int and a typo in a field name is caught before you run.

How keys become field names

A docopt key is normalized to a Python identifier: the leading -/-- is dropped, the surrounding </> is stripped, letters are lowercased, and spaces and hyphens become underscores. So --dry-run binds to dry_run, <input file> to input_file, NAME to name, and a command add to add. Every field the schema declares must have exactly one matching usage element, or binding raises DocoptLanguageError.

The four schema shapes

schema= accepts any of:

  • a @dataclasses.dataclass
  • a TypedDict
  • a Cli subclass (class-first API)
  • a pydantic model (reflective, optional - no import pydantic in the core)

Dataclass

The plain case: annotate the fields, and each is coerced to its declared type.

import dataclasses
from docopt2 import docopt

@dataclasses.dataclass
class Args:
    host: str
    port: int
    verbose: bool

doc = "Usage: prog [--verbose] <host> <port>"
docopt(doc, "--verbose 127.0.0.1 8080", schema=Args, complete=False)
# Args(host='127.0.0.1', port=8080, verbose=True)

TypedDict

Same field declarations, but the result is a plain dict whose values are typed by the TypedDict.

A total=False class, or per-field NotRequired[...], marks a key that may be left out when its element is absent.

from typing import TypedDict
from docopt2 import docopt

class Args(TypedDict):
    host: str
    port: int
    verbose: bool

doc = "Usage: prog [--verbose] <host> <port>"
docopt(doc, "127.0.0.1 8080", schema=Args, complete=False)
# {'host': '127.0.0.1', 'port': 8080, 'verbose': False}

Cli base class

The class-first form: subclass Cli, put the usage in __cli_doc__, and call .parse(argv).

It returns an instance typed as the subclass, so the doc and the schema live together in one class.

from docopt2 import Cli

class Server(Cli):
    __cli_doc__ = "Usage: server [--verbose] <host> <port>"
    host: str
    port: int
    verbose: bool

server = Server.parse("--verbose 127.0.0.1 8080", complete=False)
server.host, server.port, server.verbose   # ('127.0.0.1', 8080, True)

Pydantic model

A pydantic model is detected reflectively (by its model_validate method), so the core never imports pydantic and the dependency stays optional. The mapping is remapped to field names and handed to pydantic, which does its own validation and coercion.

import pydantic
from docopt2 import docopt

class Settings(pydantic.BaseModel):
    host: str
    port: int
    verbose: bool

doc = "Usage: prog [--verbose] <host> <port>"
docopt(doc, "127.0.0.1 8080", schema=Settings, complete=False)
# Settings(host='127.0.0.1', port=8080, verbose=False)

Pydantic coerces itself

With a pydantic model, docopt does not run the coercion below. pydantic validates every field.

  • Keys are matched to the model's field names and aliases, since pydantic validates by alias.
  • A parsed element the model does not declare is dropped rather than rejected.

Coercion

For a dataclass, TypedDict, or Cli subclass, each parsed value is coerced from its docopt-native form to the declared field type. The native form is a str, a container of str, a bool, or an int flag count.

The supported set is closed:

Annotation Coerced with Note
str unchanged the raw token
int int(value) also a repeatable flag's count
float float(value)
bool truthiness of the flag must map to a flag, not a value-bearing element
list[T], list each item coerced to T T defaults to str
T \| None None stays None, else coerce to T for an optional element
T \| U coerced to the first member that is not None so int \| str yields an int
Annotated[T, ...] the metadata is dropped, T is coerced as a validation library would attach it
Literal["a", "b"] the value, if it is one of the literals a closed set of choices, validated
enum.Enum subclass EnumType(value) matched by member value
pathlib.Path Path(value)
decimal.Decimal Decimal(value)
uuid.UUID UUID(value)
datetime.datetime datetime.fromisoformat(value) ISO 8601
datetime.date date.fromisoformat(value) ISO 8601
datetime.time time.fromisoformat(value) ISO 8601

Any other annotation raises DocoptLanguageError (typed docopt cannot coerce it).

import dataclasses, enum
from datetime import datetime
from decimal import Decimal
from docopt2 import docopt

class Level(enum.Enum):
    LOW = "low"
    HIGH = "high"

@dataclasses.dataclass
class Job:
    when: datetime
    level: Level
    retries: int
    ratio: float
    price: Decimal
    tags: list[str]

doc = "Usage: run <when> <level> <retries> <ratio> <price> <tags>..."
argv = "2026-07-11T09:00 high 3 0.75 9.99 web api"

docopt(doc, argv, schema=Job, complete=False)
# Job(when=datetime.datetime(2026, 7, 11, 9, 0),
#     level=<Level.HIGH: 'high'>, retries=3, ratio=0.75,
#     price=Decimal('9.99'), tags=['web', 'api'])

An absent element yields None, so its field must be optional (T | None) or have a default:

@dataclasses.dataclass
class Args:
    host: str
    label: str | None = None

docopt("Usage: prog <host> [<label>]", "127.0.0.1", schema=Args, complete=False)
# Args(host='127.0.0.1', label=None)

When coercion fails

The two failure modes are distinct.

A user value that cannot be coerced (say int("eighty")) raises DocoptExit, the same exception a non-matching argv raises. It exits with the exit_code (1 by default), so bad input is reported like any other command-line error.

It renders as the same two-span diagnostic: a caret under the value in the argv, cross-referenced to the usage element that typed it.

For a closed set of choices, a Literal[...] or an Enum, the diagnostic also lists the valid values (expected one of debug, info, warn).

A mistyped value gets a spell-checked did you mean suggestion (inof -> did you mean info?, transpositions included), so the user sees exactly what is allowed rather than a bare type name.

@dataclasses.dataclass
class P:
    port: int

docopt("Usage: prog <port>", "eighty", schema=P)   # raises the diagnostic below
error: invalid value for `<port>` | | in the arguments: | eighty | ^^^^^^ expected int | | in the usage: | Usage: prog <port> | ^^^^^^ typed as int | = note: `eighty` is not a valid int

A schema that disagrees with the usage raises DocoptLanguageError. That is a programmer error, not a user one:

  • a field with no matching element
  • two elements colliding on one field
  • a non-optional field bound to an element that may be absent
  • an unsupported annotation
  • a bool field mapped to a value-bearing element rather than a flag

The Arguments mapping

Without a schema, docopt returns an Arguments mapping - a dict subclass of element names to values. Alongside the items it carries two extras:

  • provided, the frozenset of names actually supplied in argv, so a value left at its [default: ...] is distinguishable from one the user typed. was_given(name) is the membership test.
  • extra, the surplus tokens kept when allow_extra=True lets a partial match through.
doc = "Usage: prog [--port=<n>]\n\nOptions:\n  --port=<n>  Port [default: 80]."

args = docopt(doc, "", complete=False)
args["--port"], args.was_given("--port")        # ('80', False)  -> defaulted

args = docopt(doc, "--port 9000", complete=False)
args["--port"], args.was_given("--port")        # ('9000', True) -> explicit

Where a value came from

was_given answers did the user type this? source(name) answers the sharper which layer supplied it?, when an option has [env:]/[config:] fallbacks.

It returns a Source enum member: CLI, ENV, CONFIG, or DEFAULT. So you can log or branch on provenance instead of guessing.

import os
from docopt2 import Source, docopt

doc = """Usage: prog [--port=<n>]

Options:
  --port=<n>  Port [default: 80] [env: APP_PORT] [config: server.port]."""
cfg = {"server": {"port": 8080}}

os.environ.pop("APP_PORT", None)
args = docopt(doc, "", config=cfg, complete=False)
args["--port"], args.source("--port")   # ('8080', Source.CONFIG) - config file

os.environ["APP_PORT"] = "7000"
args = docopt(doc, "--port=9000", config=cfg, complete=False)
args.source("--port") is Source.CLI     # True - the command line still wins
doc = "Usage: prog <cmd>"
args = docopt(doc, "run leftover1 leftover2", allow_extra=True, complete=False)
args.extra                              # ['leftover1', 'leftover2']

See also