03.02 — Closures comme primitive d’inversion de contrôle#

Pas de DI container. Pas de framework d’injection. Une closure est la primitive d’injection d’Ocarina.

L’idée#

Une closure capture des valeurs dans son scope englobant. Quand on retourne une fonction depuis une autre fonction, les paramètres de l’extérieur sont disponibles à l’intérieur, même après que la fonction extérieure ait rendu la main.

def make_handler(logger: ILogger) -> Callable[[str], FailureHandler]:
    def make_failure_handler(msg: str) -> FailureHandler:
        def actual_handler(exc: Exception) -> None:
            logger.error(msg, exc=exc)
        return actual_handler
    return make_failure_handler

C’est aussi du currying : make_handler(logger)(msg)(exc). Chaque appel capture une nouvelle couche d’environnement.

Rappel du Holy Book#

# ocarina-example/lib/ext/ocarina/adapters/selenium/logs.py
def create_just_log_error(*, logger: ILogger) -> Callable[[str], FailureHandler]:
    return lambda msg: lambda exc: logger.error(msg, exc=exc)
NiveauCaptureAppel typique
1loggercreate_just_log_error(logger=logger)
2msgjust_log_error("Failed to do X")
3excpassé en arg par le framework
just_log_error = create_just_log_error(logger=logger)

return [
    drive_page(
        act(on_homepage, open_homepage)
            .failure(just_log_error("Failed to open the homepage..."))    # ← niveau 2
            ...
    )
]

Le just_log_error("Failed to open...") retourne un FailureHandler qui, quand appelé par le framework avec l’exception, écrit le message.

Pourquoi pas un objet avec méthodes ?#

Et bien PARCE QUE.

L’IoC concrète d’Ocarina#

L’inversion de contrôle dans Ocarina est essentiellement « le framework appelle ton code » plutôt que « ton code appelle le framework ».

1. create_act(on_failure=...)#

def act(pom: TPOM, action: Callable[[TPOM], TPOM]) -> ActionStart[TPOM]:
    def failure_hook(pom: TPOM, exc: Exception) -> Fail:
        if is_http_error_page(pom):
            return Fail(error=HttpErrorPageReachedError(...))
        return Fail(error=exc)
    return create_act(pom, action, on_failure=failure_hook)

→ Le framework appelle ton failure_hook quand l’action lève. Ton hook a accès à pom (capturé en argument) et à exc. Tu décides du Fail retourné.

2. Scenario.setupScenario.teardown#

return Scenario(
    setup=lambda: seed_test_user(logger=logger),         # closure logger
    teardown=lambda: delete_test_user(logger=logger),    # closure logger
    test_chain=[...],
)

→ Le framework appelle ton setup au bon moment. Tu lui donnes accès à logger via une closure si tu veux.

3. Watcher.callback#

def my_callback(watcher: Watcher) -> None:
    elements = watcher.driver.execute_script("...")
    for el in elements:
        watcher.report(f"detected: {el}")

scenario = Scenario(
    test_chain=[...],
    watchers=[Watcher(callback=my_callback, name="cookies", poll_interval=0.8)],
)

→ Le framework appelle ton my_callback toutes les “poll_interval” secondes. Tu lui passes le Watcher (qui te donne driver, cache, report).

4. match_page(when(condition=...))#

match_page(branches=[
    when(check.has_cookies_banner, name="A", then=[...]),
    when(check.has_not_cookies_banner, name="B", then=[...]),
])

→ Le framework appelle ta condition au runtime pour décider de la branche.

5. bootstrap(post_exec=...)#

def _post_exec(results: TestCycleResults) -> None:
    pretty_print_results(results, with_colors=True)
    if has_test_cycle_failed(results):
        sys.exit(1)

bootstrap(test_cycle=..., run_plugins=..., post_exec=_post_exec)

→ Le framework appelle ta _post_exec à la fin du cycle.

Connecteurs paramétrés#

Pattern du Holy Book (chapitre « Premiers obstacles du monde réel ») :

def enter_xxx_key(p: CorsicamonEnterXXXKeyPage) -> CorsicamonEnterXXXKeyPage:
    return p.enter_xxx_key()

def enter_xxx_key_with_retries(
    *, retries: int, logger: ILogger,
) -> Callable[[CorsicamonEnterXXXKeyPage], CorsicamonEnterXXXKeyPage]:

    def unwrapped(p: CorsicamonEnterXXXKeyPage) -> CorsicamonEnterXXXKeyPage:
        return p.enter_xxx_key_with_retries(retries=retries, logger=logger)

    return unwrapped

Il suffit de retourner le def avec la signature attendue, à l’intérieur d’une fonction qui capture les paramètres. C’est une closure.

  • enter_xxx_key est un connector direct.
  • enter_xxx_key_with_retries(retries=3, logger=logger) retourne un connector qui sait combien de retries et quel logger utiliser. Le framework reçoit ce connector et l’appelle avec un (p: CorsicamonEnterXXXKeyPage).

Pourquoi#

Le Holy Book (chapitre 1.3) :

M’imposer leur incompréhension de l’évaluation paresseuse et de l’IoC comme des vérités absolues.

L’IoC d’Ocarina est explicite, locale, typée. Pas de magie.

BénéficeComparaison
Aucun setup globalPas de inject(), pas de bind(...) à écrire avant de tester.
Tests triviauxPour tester create_just_log_error(logger=mock_logger)(msg)(exc), on fait juste ça. Aucune fixture.
Type-safeLe checker vérifie la signature à chaque niveau de currying.
Refactor friendlyRenommer un paramètre traverse tout le call graph proprement.

Données → fonctions#

Le Holy Book le formule autrement dans le chapitre « Premiers retours » :

La donnée brute.

Dans Ocarina, « la donnée brute » inclut les fonctions comme valeurs de première classe. Un Effect est une donnée. Un Thunk[T] est une donnée. Une closure est une donnée enrichie. On les passe, on les compose, on les stocke. C’est algébrique.