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")
import argparse
from typing import Union
from config import ServeConfig, MigrateConfig
Config = Union[ServeConfig, MigrateConfig]
def _serve_from_ns(ns: argparse.Namespace) -> ServeConfig:
return ServeConfig(
host=ns.host,
port=ns.port,
workers=ns.workers,
reload=ns.reload,
)
def _migrate_from_ns(ns: argparse.Namespace) -> MigrateConfig:
return MigrateConfig(
database_url=ns.database_url,
revision=ns.revision,
dry_run=ns.dry_run,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="app")
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
serve = sub.add_parser("serve", help="run the http server")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8000)
serve.add_argument("--workers", type=int, default=1)
serve.add_argument("--reload", action="store_true")
serve.set_defaults(factory=_serve_from_ns)
migrate = sub.add_parser("migrate", help="apply database migrations")
migrate.add_argument("database_url")
migrate.add_argument("--revision", default="head")
migrate.add_argument("--dry-run", action="store_true", dest="dry_run")
migrate.set_defaults(factory=_migrate_from_ns)
return parser
def parse_config(argv=None) -> Config:
parser = build_parser()
ns = parser.parse_args(argv)
if getattr(ns, "factory", None) is None:
parser.error("a subcommand is required")
return ns.factory(ns)
import sys
from config import ServeConfig, MigrateConfig, ConfigError
from parser import parse_config, Config
def handle_serve(cfg: ServeConfig) -> int:
print(f"serving on {cfg.host}:{cfg.port} ({cfg.workers} workers)")
return 0
def handle_migrate(cfg: MigrateConfig) -> int:
action = "would apply" if cfg.dry_run else "applying"
print(f"{action} migration {cfg.revision} -> {cfg.database_url}")
return 0
HANDLERS = {
ServeConfig: handle_serve,
MigrateConfig: handle_migrate,
}
def dispatch(config: Config) -> int:
handler = HANDLERS[type(config)]
return handler(config)
def main(argv=None) -> int:
try:
config = parse_config(argv)
except ConfigError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
return dispatch(config)
if __name__ == "__main__":
sys.exit(main())
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: true,
});
/** @type {import('next').NextConfig} */
Next.js bundle analyzer for targeted performance work
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
Share this code
Here's the card — post it anywhere.