python 122 lines · 3 tabs

Typed Argparse Subcommands with Dataclasses and Handler Dispatch

Shared by codesnips Jul 2026
3 tabs
from dataclasses import dataclass


class ConfigError(Exception):
    pass


@dataclass(frozen=True)
class ServeConfig:
    host: str
    port: int
    workers: int
    reload: bool

    def __post_init__(self):
        if not (1 <= self.port <= 65535):
            raise ConfigError(f"port out of range: {self.port}")
        if self.workers < 1:
            raise ConfigError("workers must be >= 1")
        if self.reload and self.workers != 1:
            raise ConfigError("reload requires exactly one worker")


@dataclass(frozen=True)
class MigrateConfig:
    database_url: str
    revision: str
    dry_run: bool

    def __post_init__(self):
        if not self.database_url.startswith(("postgres://", "postgresql://")):
            raise ConfigError("database_url must be a postgres URL")
3 files · python Explain with highlit

This snippet shows a clean way to bridge Python's argparse — which produces a loosely-typed Namespace — into strongly-typed configuration objects, then dispatch each subcommand to its own handler. The pattern matters because raw Namespace access (args.foo) is stringly-typed, easy to typo, and invisible to type checkers; converting to dataclasses gives editors and mypy something concrete to reason about while keeping argparse's ergonomics.

In config.py, each subcommand gets its own frozen dataclass: ServeConfig and MigrateConfig. These are the typed shapes the rest of the program consumes. frozen=True makes them immutable value objects, so once parsing is done the config can be passed around without fear of accidental mutation. The __post_init__ on ServeConfig is where domain validation lives — port range and worker count are checked here rather than scattered through the parser, so the invariant holds no matter how the object is constructed.

In parser.py, build_parser wires up the CLI. The key trick is set_defaults(factory=...): instead of branching on args.command with a big if/elif, each subparser stores a callable that knows how to turn the Namespace into its dataclass. from_namespace classmethods do the actual field mapping. This keeps the parser declarative and colocates each command's argument-to-config translation with... nothing extra — the factory is just the dataclass constructor logic. parse_config runs the parser, guards the no-subcommand case with parser.error, and calls the stored factory, returning a typed object whose union type the caller can narrow.

In main.py, HANDLERS maps each config type to its handler function, and dispatch looks up the handler by type(config). Because the config classes are distinct types, this dictionary dispatch is exhaustive and refactor-safe: adding a command means adding a dataclass, a subparser, and a handler entry. The main function ties it together and translates a ConfigError into a non-zero exit code with a clean message.

The trade-off is a little boilerplate per command, but in exchange validation is centralized, the Namespace never leaks past parsing, and the type checker catches mistakes that string keys would hide. This scales far better than a monolithic argument handler once a tool grows past two or three subcommands.


Related snips

Share this code

Here's the card — post it anywhere.

Typed Argparse Subcommands with Dataclasses and Handler Dispatch — share card
Link copied