02.11.01 — CliBuilder + CliArg + _SilentArgumentParser

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.

02.11.02 — CliStore[TKeys] + _CliField[T] + phantom_validate

02.11.02 — CliStore[TKeys] + _CliField[T] + phantom_validate#

Fichiers source : src/ocarina/opinionated/cli/store.py, src/ocarina/opinionated/cli/phantoms.py

Store 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._value

1. 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 ».

02.11.03 — Auto CLI store + flags + validation

02.11.03 — Auto CLI store + flags + validation#

Fichier source : src/ocarina/opinionated/cli/selenium/create_cli_store.py

Flags#

FlagDefaultTypeValidation
--driver-path""stris_file (sauf --browser safari)
--profile-pathNonestris_none OR is_dir
--browserNone (requis)strchoices argparse (par OS)
--not-headlessFalse (= headless par défaut)bool (store_true)phantom
--workers5intis_positive + is_not_zero
--logger"terminal+file"strchoices=LOGGERS_CHOICES
--wait-timeout10int≤ 60, > 0
--dont-force-delete-tmp-dirsFalsebool (store_true)phantom
--only[]list[str] (nargs="+")phantom + mutex
--exclude[]list[str] (nargs="+")phantom + mutex
_DEFAULT_WORKERS_AMOUNT = 5
_DEFAULT_LOGGER: SupportedLogger = "terminal+file"
_DEFAULT_BROWSER_AUTOMATION_TIMEOUT = 10
_MAX_BROWSER_AUTOMATION_TIMEOUT = 60

Literal[...]#

type SeleniumCliStoreKeys = Literal[
    "driver_path", "profile_path", "browser", "headless", "workers",
    "logger", "wait_timeout", "force_delete_tmp_dirs", "only", "exclude",
]

Ces clés sont l’unique vocabulaire du store. Les call-sites les utilisent : store.get("workers"), store.get("browser"), etc.

02.11.04 — Loggers : PrintLogger, FileLogger, PrintAndFileLogger, MutedLogger

02.11.04 — Loggers : PrintLogger, FileLogger, PrintAndFileLogger, MutedLogger#

Dossier source : src/ocarina/opinionated/loggers/

Quatre implémentations canoniques d’ILogger, plus une factory create_matching_logger. Toutes opt-in.

create_matching_logger#

LOGGERS_CHOICES = ("terminal", "file", "terminal+file", "muted")

def create_matching_logger(mode: SupportedLogger) -> ILogger:
    if mode == "terminal":
        return PrintLogger()
    if mode == "file":
        return FileLogger(base_dir=get_default_log_dir())
    if mode == "terminal+file":
        return PrintAndFileLogger(base_dir=get_default_log_dir())
    if mode == "muted":
        return MutedLogger()
    raise ValueError(f"Unsupported logger mode: {mode}")

Et :

def get_default_log_dir() -> Path:
    return Path.cwd() / ".ocarina_logs"

→ Par défaut, les logs vont dans .ocarina_logs/ à la racine du projet utilisateur. Gitignored par convention.

02.11.05 — Plugins de rapport

02.11.05 — Plugins de rapport#

Dossier source : src/ocarina/opinionated/plugins/reports/

pretty_print_results, results_to_json, generate_docx_proof, timing. Tous opt-in. Exécutables séquentiellement ou en parallèle via run_plugins.

pretty_print_results#

# src/ocarina/opinionated/plugins/reports/pretty_print_results.py
def pretty_print_results(results: TestCycleResults, *, with_colors: bool = False) -> None:
    """Display the full test results."""
Campaign
• Suite
  > Test case 1
    » PASSED
  > Test case 2
    » FAILED
      → Error message
        ⫸ At step 3
  > Test case 3
    » SKIPPED

Test results:
1 FAILED | 1 PASSED | 1 SKIPPED
AspectDétail
Hiérarchie3 niveaux : campagne , suite >, cas »
NiveauxPASSED (vert), FAILED (rouge), SKIPPED (gris).
ErreurMessage + step number où ça a fail (« At step 3 »)
SummaryComptage agrégé en bas

results_to_json#

def generate_json_results(*, results: TestCycleResults, output_dir: Path, logger: ILogger) -> None:
    """Write test results to a JSON file in results_dir."""
    output_dir.mkdir(parents=True, exist_ok=True)
    max_stem_length = 8
    max_attempts = 500
    for _ in range(max_attempts):
        filename = f"{uuid.uuid4().hex[:max_stem_length]}.json"
        file_path = output_dir / filename
        if not file_path.exists():
            break
    else:
        raise RuntimeError(f"Can't generate unique JSON filename in: {output_dir}.")
{
  "Campaign": {
    "Suite": {
      "test_case": [
        {"status": "success" | "fail", "error": "..."},
        counter,
        metadata
      ]
    }
  }
}
def _result_to_serializable(v: Any) -> dict[str, str]:
    if is_ok(v):
        return {"status": "success"}
    if is_fail(v):
        return {"status": "fail", "error": str(v.error)}
    raise TypeError(f"Expected Ok or Fail instances, but got: {type(v)}")

Le filename est un UUID hex de 8 chars → 16⁸ ≈ 4 milliards d’options. Pas de collision dans la pratique. 500 retries de sécurité.

02.11.06 — bootstrap + run_plugins

02.11.06 — bootstrap + run_plugins#

Fichier source : src/ocarina/opinionated/launcher/bootstrap.py

Point d’entrée d’un projet Ocarina.
Trois étapes : cycle.run_all → run_plugins → post_exec.

Code#

def bootstrap[T](
    *,
    test_cycle: TestCycle[T],
    run_plugins: Callable[[TestCycleResults], None],
    post_exec: Callable[[TestCycleResults], None] | None = None,
    saturate_workers: bool = True,
) -> None:
    results = test_cycle.run_all(saturate_workers=saturate_workers)
    run_plugins(results)
    if post_exec:
        post_exec(results)

Ordre#

1. cycle.run_all()  →  results
2. run_plugins(results)
3. post_exec(results) (optionnel)
ÉtapeEffetPourquoi cet ordre
1Tous les tests s’exécutentOn a besoin des results pour la suite
2Les plugins se lancent (DOCX, JSON, etc.)Doit s’exécuter avant post_exec car peut avoir besoin du temps de génération
3Post-exec (pretty_print + sys.exit)Dernière chose ; après ça, le process est mort

Typage#

run_plugins: Callable[[TestCycleResults], None]
post_exec: Callable[[TestCycleResults], None] | None = None
  • Les deux reçoivent les results en argument :