02.11.02 — CliStore[TKeys] + _CliField[T] + phantom_validate#
Fichiers source :
src/ocarina/opinionated/cli/store.py,src/ocarina/opinionated/cli/phantoms.pyStore write-once des valeurs CLI parsées. Chaque champ valide à l’écriture. Le
TKeys: Literal[...]apporte de l’autocomplétion sur les clés.
_CliField[T] — champ write-once#
class _CliField[T]:
def __init__(self, *, validate: ValidationChainBuilder[T]) -> None:
self._value: T | _Unset = _UNSET
self._validate = validate
def set(self, value: T) -> None:
if not isinstance(self._value, _Unset):
raise RuntimeError("Value already set.")
self._validate(_validate(value)).execute().raise_if_invalid()
self._value = value
def get(self) -> T:
if isinstance(self._value, _Unset):
raise RuntimeError("Value not set yet.")
return self._value1. Sentinel _Unset#
class _Unset:
pass
_UNSET = _Unset()Pourquoi pas None ? Parce que None peut être une valeur légitime (--profile-path n’est pas obligatoire ; sa valeur après parse peut légitimement être None). Une sentinelle dédiée distingue « pas encore set » de « set à None ».
2. Write-once via isinstance(self._value, _Unset)#
Une fois set() appelé, self._value est une vraie valeur (pas un _Unset). Le if not isinstance(self._value, _Unset) retourne True au deuxième set() → on lève.
C’est le contrat : un flag CLI est parsé une fois, point.
3. Validation via chaîne d’invariants#
self._validate(_validate(value)).execute().raise_if_invalid()field(*, validate)#
def field[T](*, validate: ValidationChainBuilder[T]) -> _CliField[T]:
return _CliField(validate=validate)Sucre syntaxique : on n’expose pas _CliField directement, on passe par field(...).
CliStore[TKeys: str]#
class CliStore[TKeys: str]:
def __init__(self, fields: dict[TKeys, _CliField[Any]]) -> None:
self._fields = fields
def set(self, k: TKeys, value: Any) -> None:
self._fields[k].set(value)
def get(self, k: TKeys):
return self._fields[k].get()1. TKeys: Literal[...] pour l’autocomplétion#
type SeleniumCliStoreKeys = Literal[
"driver_path", "profile_path", "browser", "headless", "workers",
"logger", "wait_timeout", "force_delete_tmp_dirs", "only", "exclude",
]
store = CliStore[SeleniumCliStoreKeys](fields={...})
store.set("workers", 4) # ✅ autocomplete
store.set("typo_key", 4) # ❌ mypy error: not assignable to SeleniumCliStoreKeys2. Valeur en Any#
Values in CliStore are stored and returned as Any. Python has no mapped types. There is no value type safety at the CliStore level — the caller is responsible for casting get() results.
Python n’a pas de mapped types (à la TypeScript) qui permettraient de dire « la clé "workers" retourne un int, la clé "browser" retourne un Literal[...] ».
Conséquence : store.get("workers") retourne Any.
Le caller doit caster :
def get_max_workers() -> int:
return cast(int, CliStoreSingleton().get("workers"))def get_max_workers() -> int:
max_workers: int = CliStoreSingleton().get("workers")
return max_workersC’est un compromis. Le bénéfice (autocomplétion des clés) prime sur le coût (cast manuel).
phantom_validate#
def _phantom_assertion(_: Any) -> None:
"""No-op predicate — always passes without any check."""
def phantom_validate(chain: ValidationStartBlock[Any]):
return chain.assert_that(_phantom_assertion)Pourquoi cette aberration apparente ?
Parce que field() exige un validate= non-optionnel, mais certains champs n’ont pas besoin de validation supplémentaire, par exemple les booléens (headless), les choix énumérés (browser déjà validé par argparse), ou les listes (only).
phantom_validate est le no-op qui satisfait la signature sans rien faire.
"browser": field(validate=phantom_validate),
"headless": field(validate=phantom_validate),
"logger": field(validate=phantom_validate),
"only": field(validate=phantom_validate),
"exclude": field(validate=phantom_validate),
"force_delete_tmp_dirs": field(validate=phantom_validate),vs champs avec validation réelle :
"workers": field(
validate=lambda chain: chain.assert_that(is_positive, msg="--workers should be a positive value")
.assert_that(is_not_zero, msg="--workers should not be zero")
),
"wait_timeout": field(
validate=lambda chain: (
chain.assert_that(is_less_than_or_equal_to(60), msg="--wait-timeout maximum is: 60")
.assert_that(is_positive, msg="--wait-timeout should be a positive value")
.assert_that(is_not_zero, msg="--wait-timeout should not be zero")
)
),CliStoreSingleton#
# src/ocarina/opinionated/cli/selenium/cli_store_singleton.py
class SeleniumCliStoreSingleton:
...Wrapper singleton autour de CliStore[SeleniumCliStoreKeys]. Permet à n’importe quel module du projet de faire CliStoreSingleton().get("workers") sans avoir à propager le store par paramètre.
Pattern : CliStoreSingleton().push(create_selenium_auto_cli_store()) est appelé une seule fois dans main.py. Tous les modules qui ont besoin d’une valeur CLI utilisent CliStoreSingleton().get(...).
Convention notée dans le CLAUDE.md du projet IA :
Opinionated CLI keys. Never rename keys read from
SeleniumCliStoreSingleton(it’s"workers", not"max_workers").