CLI Arguments Overriding Environment Variables

An argument parser that reads flags from the command line and environment with declared precedence, reporting which source supplied each value.

PythonConfig Management

Code

import os
import sys
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence


class ArgError(ValueError):
    """Raised for unknown flags, missing values or failed conversions."""


@dataclass(frozen=True)
class FlagSpec:
    name: str
    env: Optional[str] = None
    converter: Callable[[str], Any] = str
    default: Any = None
    required: bool = False


@dataclass(frozen=True)
class ParsedArgs:
    values: Dict[str, Any] = field(default_factory=dict)
    sources: Dict[str, str] = field(default_factory=dict)


def parse_args(
    argv: Sequence[str],
    specs: Sequence[FlagSpec],
    env: Optional[Mapping[str, str]] = None,
) -> ParsedArgs:
    """Parse argv with env fallback; argv always beats the environment."""
    environment = os.environ if env is None else env
    by_flag = {spec.name: spec for spec in specs}
    parsed = ParsedArgs()

    index = 0
    while index < len(argv):
        token = argv[index]
        if not token.startswith("--"):
            raise ArgError("unexpected positional argument: " + token)
        name = token[2:]
        spec = by_flag.get(name)
        if spec is None:
            raise ArgError("unknown flag: " + name)
        if name in parsed.values:
            raise ArgError("flag repeated: " + name)
        index += 1
        if index >= len(argv):
            raise ArgError("missing value for flag: " + name)
        raw = argv[index]
        index += 1
        try:
            parsed.values[name] = spec.converter(raw)
        except (TypeError, ValueError) as exc:
            raise ArgError("invalid value for %s: %s" % (name, exc)) from exc
        parsed.sources[name] = "argv"

    for spec in specs:
        if spec.name in parsed.values:
            continue
        if spec.env and spec.env in environment:
            try:
                parsed.values[spec.name] = spec.converter(environment[spec.env])
            except (TypeError, ValueError) as exc:
                raise ArgError(
                    "invalid value for %s from %s: %s" % (spec.name, spec.env, exc)
                ) from exc
            parsed.sources[spec.name] = "env:" + spec.env
            continue
        if spec.default is not None:
            parsed.values[spec.name] = spec.default
            parsed.sources[spec.name] = "default"
            continue
        if spec.required:
            raise ArgError("missing required flag: " + spec.name)

    return parsed


def _int(value: str) -> int:
    return int(value)