02.11.01 — CliBuilder + CliArg + _SilentArgumentParser#

Source file: src/ocarina/opinionated/cli/builder.py

Declarative overlay on top of argparse. Aggregates validation errors, rewrites the help output on error, and registers post-parse effects.

_SilentArgumentParser#

class _SilentArgumentParser(ArgumentParser):
    def error(self, message: str) -> Never:
        """Raise an error."""
        raise ValueError(message)

The standard ArgumentParser calls sys.exit(2) directly on error. With that turd of a default, you can’t intercept and you can’t aggregate multiple errors.

_SilentArgumentParser reroutes errors as ValueError. So CliBuilder.parse can catch and aggregate them.

Note: Never (PEP 661) is more precise than None for a function that only ever raises.

CliArg#

class CliArg:
    def __init__(
        self,
        *flags: str,
        validate: ArgValidator | None = None,
        **argparse_kwargs: Any,
    ) -> None:
        self.flags = flags
        self.validate = validate
        self.argparse_kwargs = argparse_kwargs
FieldRole
flagsThe names ("--browser", "--driver-path")
validateOptional validator (value) -> None that raises if invalid
argparse_kwargsEverything else: type=, default=, choices=, help=, nargs=, action=, metavar=

CliBuilder.parse#

def parse(self) -> Namespace:
    parser = _SilentArgumentParser(
        description=self._description,
        formatter_class=ArgumentDefaultsHelpFormatter,
    )
    for arg in self._args:
        parser.add_argument(*arg.flags, **arg.argparse_kwargs)

    try:
        namespace = parser.parse_args()
    except ValueError as exc:
        print(_INVALID_CLI_ARGUMENTS, file=sys.stderr)
        print(f"🚫  {_ucfirst(str(exc))}", file=sys.stderr)
        parser.print_help(file=sys.stderr)
        sys.exit(2)

    errors: list[str] = []

    for arg in self._args:
        if arg.validate is None:
            continue
        dest = arg.argparse_kwargs.get("dest") or arg.flags[-1].lstrip("-").replace("-", "_")
        value = getattr(namespace, dest)
        try:
            arg.validate(value)
        except Exception as exc:
            errors.append(str(exc))

    for effect in self._effects_factory(namespace):
        try:
            effect()
        except Exception as exc:
            errors.append(str(exc))
            if self._effects_fail_fast:
                break

    if errors:
        print(_INVALID_CLI_ARGUMENTS, file=sys.stderr)
        for err in errors:
            print(f"🚫  {_ucfirst(str(err))}", file=sys.stderr)
        parser.print_help(file=sys.stderr)
        sys.exit(2)

    return namespace

1. Parsing#

try:
    namespace = parser.parse_args()
except ValueError as exc:
    print(_INVALID_CLI_ARGUMENTS, file=sys.stderr)
    print(f"🚫  {_ucfirst(str(exc))}", file=sys.stderr)
    parser.print_help(file=sys.stderr)
    sys.exit(2)

argparse raises (you passed --browser=banana when choices=["chrome", "firefox"]) → we intercept, print a clear message ("🚫 Argument –browser: invalid choice: ‘banana’"), print the help, and exit.

2. Validation#

for arg in self._args:
    if arg.validate is None:
        continue
    dest = arg.argparse_kwargs.get("dest") or arg.flags[-1].lstrip("-").replace("-", "_")
    value = getattr(namespace, dest)
    try:
        arg.validate(value)
    except Exception as exc:
        errors.append(str(exc))

3. Effects#

for effect in self._effects_factory(namespace):
    try:
        effect()
    except Exception as exc:
        errors.append(str(exc))
        if self._effects_fail_fast:
            break
  • Store the parsed value in a CliStore (see 02-cli-store-phantoms.md).
  • Validate inter-arg consistency (mutex, dependencies).
  • Register an atexit cleanup (_create_dont_force_delete_tmp_dirs_effect).

Note: _effects_fail_fast=True stops at the first failing effect.
Default False → try everything, aggregate.

Why this level of indirection rather than argparse directly#

Without CliBuilderWith CliBuilder
argparse sys.exit(2) on the first invalid flagAggregation of all errors
Inline or nested validationDeclarative validation (CliArg(validate=...))
Post-parse effects written by handeffects_factory(namespace) -> Effects
argparse interleaved with business logicSeparation of concerns

Example (Selenium CLI)#

return CliBuilder(
    args=[
        CliArg("--driver-path", type=str, default="", help="Path to the Selenium driver"),
        CliArg("--profile-path", type=str, default=None, help="Path to the browser profile directory"),
        CliArg("--browser", type=str, default=None, choices=browser_choices, help="..."),
        CliArg("--not-headless", action="store_true", help="..."),
        CliArg("--workers", type=int, default=5, help="..."),
        CliArg("--logger", type=str, default="terminal+file", choices=LOGGERS_CHOICES, help="..."),
        CliArg("--wait-timeout", type=int, default=10, help="..."),
        CliArg("--dont-force-delete-tmp-dirs", action="store_true", help="..."),
        CliArg("--only", nargs="+", default=[], metavar="ID", help="..."),
        CliArg("--exclude", nargs="+", default=[], metavar="ID", help="..."),
    ],
    effects_factory=lambda ns: (
        lambda: store.set("driver_path", ns.driver_path),
        lambda: store.set("profile_path", ns.profile_path),
        # ... etc ...
        _create_validate_only_exclude_mutex_effect(ns),
        _create_validate_dependent_args_effect(ns),
        _create_validate_driver_path(ns),
        _create_dont_force_delete_tmp_dirs_effect(dont_force_delete_tmp_dirs=ns.dont_force_delete_tmp_dirs),
    ),
)

→ Important: the lambdas capture ns (the namespace) by closure. They’re deferred — they only run when parse() iterates the effects. That’s how _create_validate_dependent_args_effect(ns) can return an Effect that runs after ns is fully initialized.