02.11.01 — CliBuilder + CliArg + _SilentArgumentParser#

Fichier source : src/ocarina/opinionated/cli/builder.py

Surcouche déclarative au-dessus d’argparse. Permet d’agréger les erreurs de validation, ré-écrire la sortie d’aide en cas d’erreur, et enregistrer des effets post-parse.

_SilentArgumentParser#

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

L’ArgumentParser standard appelle sys.exit(2) directement en cas d’erreur. Avec cette merde, on ne peut pas intercepter, on ne peut pas agréger plusieurs erreurs.

_SilentArgumentParser re-route les erreurs en ValueError. Donc CliBuilder.parse peut les attraper et les agréger.

Note : le type de retour Never (PEP 661) est plus précis que None dans le cas d’une fonction qui ne fait que raise systématiquement.

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
ChampRôle
flagsLes noms ("--browser", "--driver-path")
validateValidator optionnel (value) -> None qui lève si invalide
argparse_kwargsTout le reste : type=, default=, choices=, help=, nargs=, action=, metavar=, etc.

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)

Si argparse lève (parce qu’on lui a passé --browser=banana alors que choices=["chrome", "firefox"]), on intercepte, on affiche un message clair (« 🚫 Argument –browser: invalid choice: ‘banana’ »), on imprime l’help, on quitte.

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. Effets#

for effect in self._effects_factory(namespace):
    try:
        effect()
    except Exception as exc:
        errors.append(str(exc))
        if self._effects_fail_fast:
            break
  • Stocker la valeur parsée dans un CliStore (cf. 02-cli-store-phantoms.md).
  • Valider la cohérence inter-args (mutex, dépendances).
  • Enregistrer un atexit cleanup (_create_dont_force_delete_tmp_dirs_effect).

Note : si _effects_fail_fast vaut True, on s’arrête au premier effet en erreur.
Par défaut, False → on tente tout et on agrège.

Pourquoi ce niveau d’indirection plutôt qu’argparse direct#

Sans CliBuilderAvec CliBuilder
argparse sys.exit(2) au premier flag invalideAggrégation de toutes les erreurs
Validation inline ou imbriquéeValidation déclarative (CliArg(validate=...))
Effets post-parse écrits à la maineffects_factory(namespace) -> Effects
Imbrication argparse + logique métierSéparation des concerns

Exemple (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),
    ),
)

→ Note importante : les lambda capturent ns (le namespace) en closure. Elles sont différées ; elles ne s’exécutent que quand parse() itère sur les effets. C’est ce qui permet à _create_validate_dependent_args_effect(ns) de retourner un Effect qui sera appelé après que ns est complètement initialisé.