02.11.01 — CliBuilder + CliArg + _SilentArgumentParser#
Source file:
src/ocarina/opinionated/cli/builder.pyDeclarative 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| Field | Role |
|---|---|
flags | The names ("--browser", "--driver-path") |
validate | Optional validator (value) -> None that raises if invalid |
argparse_kwargs | Everything 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 namespace1. 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(see02-cli-store-phantoms.md). - Validate inter-arg consistency (mutex, dependencies).
- Register an
atexitcleanup (_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 CliBuilder | With CliBuilder |
|---|---|
argparse sys.exit(2) on the first invalid flag | Aggregation of all errors |
| Inline or nested validation | Declarative validation (CliArg(validate=...)) |
| Post-parse effects written by hand | effects_factory(namespace) -> Effects |
| argparse interleaved with business logic | Separation 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.