02.09 — Ports : ILogger, ITakeScreenshot#
Dossier source :
src/ocarina/ports/Deux ports seulement. C’est tout. Le reste (
WebDriversPool,Screenshotter, etc.) vit dansinfra/, pas dansports/. 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éthode | Sé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 / debug | Niveaux 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.MutedLoggerfiltre tout). - Pas de
add_handler(): pas d’API à lalogging.Loggerdu stdlib. La composition se fait par instanciation de loggers différents (PrintAndFileLoggerwrappe les deux). - Pas de
child(name): la taxonomy est passée en bloc avecset_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)
continuePour 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.