02.09 — Ports : ILogger, ITakeScreenshot#

Dossier source : src/ocarina/ports/

Deux ports seulement. C’est tout. Le reste (WebDriversPool, Screenshotter, etc.) vit dans infra/, pas dans ports/. La distinction est claire : un port est une abstraction au-dessus de laquelle vit le DSL ; une infra est l’implémentation des adapters.

ILogger#

class ILogger(ABC):
    @abstractmethod
    def set_prefix(self, prefix_thunk: Thunk[str]) -> Self: ...

    @abstractmethod
    def set_domain_taxonomy(self, taxonomy: tuple[str, ...]) -> Self: ...

    @abstractmethod
    def raw(self, *args: object, stream: SupportsWrite[str] | None = None, **kwargs: object) -> None: ...

    @abstractmethod
    def critical(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def error(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def warning(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def info(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def debug(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def test_name(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def success(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def exception(self, msg: str, *args, exc: Exception | None = None, **kwargs) -> None: ...

    @abstractmethod
    def cleanup(self) -> None: ...
MéthodeSémantique
set_prefix(thunk)Préfixe paresseux appliqué à chaque log (pour timestamps, threads…). Retourne Self pour chaining.
set_domain_taxonomy(taxonomy)Définit la hiérarchie de domaine ((cycle, campaign, suite, test)). Crucial pour le FileLogger qui crée une arborescence de fichiers.
raw(*args, stream=None)Écriture brute, sans format (utilisé par les plugins de rapport).
critical / error / warning / info / debugNiveaux standards. Chaque méthode accepte exc= pour passer une exception et la formatter.
test_name(msg)Niveau custom : annonce le test en cours (logger.test_name(test.name)).
success(msg)Niveau custom : assertion réussie (utilisé par les .success(...) handlers).
exception(msg, exc=)Logue une exception avec traceback complet.
cleanup()Fin de vie : flush + close (pour le FileLogger). Recycle le fichier de log entre les retries.

Trois choses qui ne sont pas dans ILogger#

  • Pas de set_level() : le niveau est géré par l’implémentation (ex. MutedLogger filtre tout).
  • Pas de add_handler() : pas d’API à la logging.Logger du stdlib. La composition se fait par instanciation de loggers différents (PrintAndFileLogger wrappe les deux).
  • Pas de child(name) : la taxonomy est passée en bloc avec set_domain_taxonomy.

set_prefix(thunk)#

logger.set_prefix(lambda: f"[{datetime.now().isoformat()}]")

Si on passait une str, ce serait calculé au moment du set_prefix, donc figé. Avec un Thunk, le préfixe est calculé à chaque appel de log. Idéal pour les timestamps.

exceptions_logger = PrintLogger().set_prefix(
    lambda: concat_metadata(
        format_utc_date_metadata_str,
        format_current_thread_metadata_str,
    )
)

Le préfixe résultant ressemble à [UTC_DATE::2026-05-18T09:42:31.123456+00:00][THREAD::ThreadPoolExecutor-0_2].

Le [UTC_DATE::...] est consommé en aval par le plugin DOCX, qui le remplace par la date locale formatée ([05/18/2026 | 11h42:31.123456]). Le format est donc un contrat entre le logger et le plugin de génération.

set_domain_taxonomy(taxonomy)#

logger.set_domain_taxonomy(("e2e", "Dashboard login", "Login happy paths", "Login - without OTP"))

Conséquence côté FileLogger : crée e2e/Dashboard login/Login happy paths/Login - without OTP.log sous base_dir. Cf. 11-opinionated/04-loggers.md

ITakeScreenshot[Driver]#

# src/ocarina/ports/itake_screenshot.py
class ITakeScreenshot[Driver](Protocol):
    def __call__(
        self,
        driver: Driver,
        logger: ILogger,
        category: str,
    ) -> None: ...
  • Protocol (PEP 544) : c’est un structural type, n’importe quel callable avec la bonne signature est accepté.
  • Generic sur Driver : ITakeScreenshot[WebDriver] est (WebDriver, ILogger, str) -> None.
  • Pas de retour : prendre un screenshot est un Effect (au sens d’Ocarina) qui peut échouer silencieusement.
def take_screenshot(driver: WebDriver, logger: ILogger, category: str) -> None:
    create_selenium_screenshotter(driver, logger).take_screenshot(prefix=category)

→ Cf. 10-infra/03-screenshotter.md pour la mécanique interne.

Pourquoi ces deux ports et pas d’autres ?#

Pourquoi pas un IDriversPool ? Un ITestExecutor ? Un IInvariant ?

Le pattern d’extension d’Ocarina n’est pas l’héritage, c’est la composition par adapters typés. Les classes du DSL (TestSuite, TestExecutor, TestFlow, WebDriversPool) sont concrètes et acceptent des dépendances via leur __init__ ; on remplace le comportement en passant d’autres instances.

Les seules abstractions qui méritaient d’être des ports sont :

  • ILogger : parce qu’il y a plusieurs implémentations canoniques (Print, File, PrintAndFile, Muted) et l’utilisateur peut écrire la sienne.
  • ITakeScreenshot[Driver] : parce que c’est appelé partout et qu’on veut pouvoir le mocker / le rerouter (par exemple en CI : screenshot vers S3 plutôt que vers disque local).

Tout le reste est composé, pas hérité.

cleanup()#

Rappel (05-orchestration/03-test-flow-retries.md) :

if outcome.should_retry and attempt < max_attempts:
    logger_with_taxonomy.cleanup()           # ◄── ICI
    time.sleep(attempt)
    continue

Pour le FileLogger, cleanup() ferme le fichier en cours et le recycle. La tentative suivante écrit dans un fichier frais.

Pour le PrintLogger, cleanup() est un no-op.

cleanup() n’est pas un context manager __exit__#

ILogger est partagé entre les tentatives : il a la durée de vie du test, pas d’une tentative. Le pattern context manager (with logger: ...) ne marcherait pas pour réinitialiser proprement entre les retries.