02.03.02 — La machine à états du builder#

Fichier source : src/ocarina/dsl/testing_with_railway/internals/action_chain.py

C’est ici qu’est implémentée toute la mécanique ROP : le passage par quatre états successifs typés, qui interdit littéralement (au sens du type-checker) toute fantaisie syntaxique. Le DSL est aussi son propre système immunitaire.

Vue d’ensemble#

                      ┌─────────────────┐
   start(action)  →   │  ActionStart[T] │     un Action[T] = Thunk[Result[T]]
                      └────────┬────────┘
                               │ .failure(failure_handler)
                               ▼
                      ┌─────────────────┐
                      │ ActionFailure[T]│
                      └────────┬────────┘
                               │ .success(success_handler)
                               ▼
                      ┌─────────────────┐
                      │ ActionSuccess[T]│
                      └────────┬────────┘
                               │ .execute()   ── exécute action(), fire le handler
                               ▼
                      ┌─────────────────┐
                      │ ActionChain[T]  │     contient (has_failed, result)
                      └──────┬──────────┘
                             │ .then(next_action_or_start)
              ┌──────────────┴──────────────┐
              │                             │
   has_failed=True                   has_failed=False
              │                             │
              ▼                             ▼
   ┌──────────────────┐         ┌──────────────────┐
   │ NeutralAction-   │         │ ActionStart[T]   │  → cycle recommence
   │ Start[T]         │         │  (l'action suiv. │
   │ (rail d'échec :  │         │   sera exécutée) │
   │  toute la chaîne │         └──────────────────┘
   │  devient no-op,  │
   │ tout en gardant  │
   │ l'API fluide)    │
   └──────────────────┘
              │
              ▼ (.failure → .success → .execute, tout en no-op)
   ┌──────────────────┐
   │  ActionChain[T]  │   has_failed=True, result=<le Fail accumulé>
   └──────────────────┘

Les types Action, FailureHandler, SuccHandler#

type Action[T]        = Thunk[Result[T]]      # () -> Result[T]
type FailureHandler   = Callable[[Exception], None]
type SuccHandler      = Effect                # () -> None
  • Action[T] est un Thunk. Cela veut dire qu’au moment où ActionStart(action) est appelé, rien n’est exécuté. L’action est juste capturée.
  • FailureHandler reçoit l’exception, mais ne reçoit pas le Fail ni le Result. C’est volontaire : 99% des handlers veulent tracer l’exception, prendre un screenshot, et c’est tout.
  • SuccHandler est un Effect (sans argument). Idem : un handler de succès log un message statique, fait un screenshot, pas besoin du résultat.

Typestate Pattern : pourquoi on ne peut pas se tromper#

L’enchaînement est strictement linéaire. Chaque méthode renvoie un type différent, qui n’expose que la suite logique de l’enchaînement :

TypeMéthodes exposéesPas de
ActionStart[T].failure(h).success, .execute (n’existent pas !)
ActionFailure[T].success(h').failure (a déjà été appelé), .execute
ActionSuccess[T].execute().failure, .success
ActionChain[T].then(...), .has_failed(), .is_ok(), .result().failure, .success, .execute

Extraits du Holy Book (chapitre sur la composabilité des scénarios) :

Tentative 1 : Oublier .success#

drive_page(
    act(on_book_a_call_page, verify_book_call_page)
    .failure(just_log_error("..."))
)

# error: Expected type 'ActionSuccess[TPOM ≤: POMBase]', got 'ActionFailure[BookCallPage]' instead

Tentative 2 : Mettre .success avant .failure#

drive_page(
    act(on_book_a_call_page, verify_book_call_page)
    .success(log_success_with_url_and_screenshot("..."))   # ← erreur ici
)

# error:
# "ActionStart[BookCallPage]" has no attribute "success"
# Unresolved attribute reference 'success' for class 'ActionStart'

Tentative 3 : Inverser .failure et .success#

drive_page(
    act(on_book_a_call_page, verify_book_call_page)
    .success(...)
    .failure(...)
)

# error:
# "ActionStart[BookCallPage]" has no attribute "success"

Le checker refuse littéralement le code. La grammaire est type-level, pas seulement conventionnelle.

Le narrowing par act() : conservation du TPOM#

Le projet utilisateur définit act(pom: TPOM, action: Callable[[TPOM], TPOM]) -> ActionStart[TPOM]. Le TPOM est conservé tout au long de la chaîne :

on_homepage = Homepage(driver=driver)
act(on_homepage, verify_book_call_page)
#                ^^^^^^^^^^^^^^^^^^^^^
# error: Argument 2 to "act" has incompatible type
#   "Callable[[BookCallPage], BookCallPage]";
#   expected "Callable[[Homepage], Homepage]"

Mélanger des act hétérogènes dans un même drive_page est aussi refusé :

drive_page(
    act(on_homepage, ...).failure(...).success(...),
    act(on_book_a_call_page, verify_book_call_page).failure(...).success(...),
    #   ^^^^^^^^^^^^^^^^^^^
    # error: Expected type 'ActionSuccess[Homepage]',
    #        got 'ActionSuccess[BookCallPage]' instead
)

Cette contrainte est ce qui force transition de page = nouveau drive_page. C’est une question de sémantique : ça aide à la revue (voir les transitions de page, identifier rapidement une capture d’écran manquante). Cf. 05-create-act-hooks.md

ActionSuccess.execute()#

def execute(self) -> ActionChain[T]:
    result = self.__action__()                              # 1. exécute l'action

    if is_fail(result):
        self.__failure_handler__(result.error)              # 2a. fire failure
        return ActionChain(has_failed=True, result=result)  #     rail d'échec

    self.__success_handler__()                              # 2b. fire success
    return ActionChain(has_failed=False, result=result)     #     rail de succès
  1. L’action est appelée sans argument (c’est un Thunk[Result[T]]). Tout son contexte a été capturé en closure par create_act.
  2. Le handler de failure reçoit result.error.
  3. Le handler de success ne reçoit rien.
  4. ActionChain capture deux choses : un drapeau (has_failed: bool) et le Result lui-même. Le drapeau permet à .then() de prendre une décision sans refaire un isinstance (hot-path).

__action__, __failure_handler__, __success_handler__#

Triple naming en dunder (double underscores).

Ce sont des attributs internes au framework. La double underscore notation est utilisée pour signaler « ne touche pas, c’est de la plomberie ». chain_actions les lit pour reconstruire la chaîne :

def reducer(chain: ActionChain[T], step: ActionSuccess[T]) -> ActionChain[T]:
    if chain.has_failed():
        return chain
    return (
        chain.then(step.__action__)
        .failure(step.__failure_handler__)
        .success(step.__success_handler__)
        .execute()
    )

L’inspiration directe vient de xhtmlboi.github.io/articles/yocaml.html. Le principe : partir d’une composition fonctionnelle « plate », explicite, avec potentiellement beaucoup de parenthèses imbriquées ; puis introduire un opérateur (ici la chaîne .failure().success().execute() + .then()) qui aplatit la lecture tout en conservant le typage. Ocarina applique exactement ce pattern, sur des actions Selenium plutôt que des rules OCaml.

Récapitulatif#

PrincipeConséquence
Linéarité du builderErreur mypy si on saute une étape ou si on change l’ordre
@final sur chaque classePas d’héritage utilisateur des classes ROP
Action[T] est un ThunkÉvaluation paresseuse ; tout est composable comme valeur
Une seule conversion except → Fail (dans create_act)Aucune exception en aval ; tout est valeur typée
ActionChain porte (has_failed, result).then(...) décide entre ActionStart et NeutralActionStart via un simple booléen
__action__ etc. en dunderPlomberie, relations bidirectionnelles dans l’implémentation du framework