02.10.01 — WebDriversPool[Driver]

02.10.01 — WebDriversPool[Driver]#

Fichier source : src/ocarina/infra/drivers_pool.py

Pool thread-safe de drivers, max concurrence garantie par sémaphore, warmup asynchrone surveillé, shutdown propre. Pas de réutilisation : chaque acquire() détruit son driver à la fin (état propre).

Constructeur#

@final
class WebDriversPool[Driver]:
    def __init__(
        self,
        create_driver: Thunk[BuiltWebDriver[Driver]],
        max_size: int,
        warmup_timeout: float | None = None,
    ) -> None:
        self._create_driver = create_driver
        self._pool: Queue[BuiltWebDriver[Driver]] = Queue(max_size)
        self._semaphore = Semaphore(max_size)
        self._warmup_timeout = (
            warmup_timeout if warmup_timeout is not None and warmup_timeout > 0.1
            else 60.0 * 5
        )
PrimitiveRôle
Queue(max_size)File des drivers pré-créés disponibles.
Semaphore(max_size)Garantit que jamais plus de max_size drivers ne vivent simultanément (qu’ils soient en queue ou acquis).
warmup_timeoutDélai max sans progression de warmup avant de lever WarmupTimeoutError. Par défaut 300s.

acquire()#

@contextmanager
def acquire(self) -> Iterator[Driver]:
    try:
        driver, dispose = self._pool.get_nowait()
    except Empty:
        self._semaphore.acquire()
        try:
            driver, dispose = self._create_driver()
        except Exception:
            self._semaphore.release()
            raise

    try:
        yield driver
    finally:
        with suppress(Exception):
            dispose()
        self._semaphore.release()
              acquire()
                  │
                  ▼
   ┌──────────────────────────────────┐
   │ pool.get_nowait()                │── OK ─► driver, dispose         ← cas warmup
   └──────────────┬───────────────────┘
                  │ Empty
                  ▼
   ┌──────────────────────────────────┐
   │ sem.acquire()                    │  ← bloque si N drivers vivants
   └──────────────┬───────────────────┘
                  ▼
   ┌──────────────────────────────────┐
   │ create_driver()                  │── leve ─► sem.release(); raise
   └──────────────┬───────────────────┘
                  ▼
   ┌──────────────────────────────────┐
   │ yield driver                     │  ← caller utilise
   └──────────────┬───────────────────┘
                  ▼
                finally :
                  with suppress : dispose()       ← driver détruit
                  sem.release()                   ← rend une place

1. Pas de réutilisation#

Queue.get_nowait() consomme l’entrée. Une fois sorti, le driver n’est jamais remis dans la queue. À la fin (finally), il est disposé.

02.10.02 — DriverBuilder[Driver]

02.10.02 — DriverBuilder[Driver]#

Fichier source : src/ocarina/infra/driver_builder.py

Encapsule la gestion du profil (souvent une copie tmp d’un répertoire utilisateur) et produit la paire (driver, dispose) attendue par la pool.

Pourquoi un builder ?#

Quand on construit un driver Selenium avec un profil, il faut :

  1. Copier le profil dans un dossier temporaire (sinon Firefox/Chrome locked sur le profil original).
  2. Lancer le driver en pointant vers le dossier temporaire.
  3. À la fin : driver.quit() puis supprimer le dossier temporaire.

DriverBuilder factorise ces trois étapes :

02.10.03 — Screenshotter[TDriver]

02.10.03 — Screenshotter[TDriver]#

Fichier source : src/ocarina/infra/screenshotter.py

Utilitaire de capture d’écran générique, thread-safe, agnostique du driver via un Protocol, configurable via une dataclass. Supporte le burst.

Protocol#

class ScreenshotDriver(Protocol):
    def save_screenshot(self, path: str) -> bool:
        """Save standard viewport screenshot."""

C’est tout. N’importe quel objet qui a une méthode save_screenshot(path: str) -> bool est utilisable. Selenium WebDriver l’a nativement (driver.save_screenshot), Playwright peut être wrappé, un fake driver en test l’expose trivialement.

ScreenshotterConfig[TDriver]#

@dataclass(frozen=True)
class ScreenshotterConfig[TDriver: ScreenshotDriver]:
    output_dir: Path
    file_ext: str = ".png"
    health_check: HealthCheck[TDriver] | None = None
    save_full_page: SaveFullPageScreenshot[TDriver] | None = None
    default_burst_delay: float = 0.5
    max_filename_retries: int = 500
    uuid_length: int = 8
ChampTypeRôleDefault
output_dirPathRépertoire des screenshots — 
file_extstrExtension (.png, .jpg).png
health_checkHealthCheck[TDriver] | None(driver) -> None qui lève si driver mortNone
save_full_pageSaveFullPageScreenshot[TDriver] | None(driver, path) -> bool pour les screenshots pleine page (Firefox uniquement)None
default_burst_delayfloatDélai entre 2 shots en mode burst0.5s
max_filename_retriesintMax essais pour générer un nom unique500
uuid_lengthintLongueur du suffix UUID dans le nom8

Immutable. On configure une fois et on exporte.

02.10.04 — ActCounter + ThreadsBasedActCounter

02.10.04 — ActCounter + ThreadsBasedActCounter#

Compteur thread-local du nombre d’act exécutés par tentative. Permet de reporter au rapport « ce test a fait 17 steps avant d’échouer au step 18 ».

Interface#

# src/ocarina/infra/act_counter.py
class ActCounter:
    def get(self) -> int: ...
    def reset(self) -> None: ...
    def incr_act_call_count(self) -> None: ...

Implémentation par défaut#

# src/ocarina/opinionated/infra/act_counter.py
from threading import local

_thread_local = local()
_COUNTER_KEY: Final[str] = "ocarina_counter"


class ActCounter(_ActCounter):
    def get(self) -> int:
        return getattr(_thread_local, _COUNTER_KEY, 0)

    def reset(self) -> None:
        setattr(_thread_local, _COUNTER_KEY, 0)

    def incr_act_call_count(self) -> None:
        if not hasattr(_thread_local, _COUNTER_KEY):
            self.reset()
        setattr(_thread_local, _COUNTER_KEY, getattr(_thread_local, _COUNTER_KEY) + 1)
  • threading.local() : un namespace dont les attributs sont par thread. Chaque thread a sa propre valeur.
  • _COUNTER_KEY = "ocarina_counter" : la clé d’attribut.
  • Pas de lock : chaque thread n’écrit que sa propre valeur, donc pas de race condition.

Pourquoi thread-local plutôt qu’un compteur partagé + lock ?#

Ocarina est architecturé de sorte qu’un thread = un test. Et aucune envie d’introduire toute une state monad ou quoi, autant placer cette « impureté » (qui reste un “état”) ici. C’est tout.

02.10.05 — Adapters Selenium

02.10.05 — Adapters Selenium#

Dossier source : src/ocarina/infra/selenium/

Tout ce qui matérialise les abstractions (POMBase, WebDriversPool, Screenshotter) en version Selenium. Vit hors du DSL pur — et depuis la 1.1.3 il a un jumeau : src/ocarina/infra/playwright/ livre exactement les mêmes contrats en version Playwright. Changer de backend, c’est désormais choisir un dossier, pas l’écrire.

Fichiers du dossier#

FichierRôle
create_driver.py_build_firefox, _build_chrome, _build_edge, _build_safari + dispatch
create_drivers_pool.pycreate_selenium_drivers_pool (utilise DriverBuilder + WebDriversPool)
create_screenshotter.pycreate_selenium_screenshotter + _selenium_save_full_page (Firefox-only)
driver_healthcheck.pydriver_healthcheck(driver) — ping driver.title
mixins.pySeleniumTitleMixin

create_driver.py#

def _build_firefox(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    service = FirefoxService(executable_path=driver_path)
    options = FirefoxOptions()
    if headless:
        options.add_argument("-headless")
    if profile_path:
        options.profile = FirefoxProfile(profile_path)
    driver = Firefox(service=service, options=options)
    driver.implicitly_wait(wait_timeout)
    return driver


def _build_chrome(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    service = ChromeService(executable_path=driver_path)
    options = ChromeOptions()
    if headless:
        options.add_argument("--headless=new")
    if profile_path:
        options.add_argument(f"--user-data-dir={profile_path}")
    driver = Chrome(service=service, options=options)
    driver.implicitly_wait(wait_timeout)
    return driver


def _build_edge(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    # … identique à Chrome avec EdgeService/EdgeOptions/Edge …


def _build_safari(*, profile_path, driver_path, headless, wait_timeout) -> WebDriver:
    # … Safari ne supporte ni driver_path (utilise safaridriver natif macOS),
    #   ni profile_path (pas de profile custom), ni headless …

1. implicitly_wait défini une seule fois#

Chaque builder appelle driver.implicitly_wait(wait_timeout) une fois. Donc, tout find_element qui ne trouve pas l’élément immédiatement attend jusqu’à wait_timeout secondes avant de lever NoSuchElementException.

02.10.06 — L'acteur Playwright : un thread propriétaire

02.10.06 — L’acteur Playwright : un thread propriétaire#

Dossier source : src/ocarina/infra/playwright/

L’adapter Playwright reprend la structure de l’adapter Selenium fichier pour fichier (create_driver, create_drivers_pool, create_screenshotter, driver_healthcheck, mixins), avec un fichier en plus : driver.py. Ce fichier mérite un chapitre à lui seul. C’est lui qui réconcilie l’API sync de Playwright, intrinsèquement liée à un thread, avec le modèle threadé d’Ocarina (pool, warmup, Watcher).

Le problème : l’API sync de Playwright est thread-affine#

L’API sync de Playwright lie chaque objet qu’elle renvoie (Playwright, Browser, BrowserContext, Page, Locator, …) au thread qui a appelé sync_playwright().start(). Sous le capot, c’est un greenlet épinglé à ce thread. Y toucher depuis un autre thread lève :