02.11.04 — Loggers : PrintLogger, FileLogger, PrintAndFileLogger, MutedLogger#
Dossier source :
src/ocarina/opinionated/loggers/Quatre implémentations canoniques d’
ILogger, plus une factorycreate_matching_logger. Toutes opt-in.
create_matching_logger#
LOGGERS_CHOICES = ("terminal", "file", "terminal+file", "muted")
def create_matching_logger(mode: SupportedLogger) -> ILogger:
if mode == "terminal":
return PrintLogger()
if mode == "file":
return FileLogger(base_dir=get_default_log_dir())
if mode == "terminal+file":
return PrintAndFileLogger(base_dir=get_default_log_dir())
if mode == "muted":
return MutedLogger()
raise ValueError(f"Unsupported logger mode: {mode}")Et :
def get_default_log_dir() -> Path:
return Path.cwd() / ".ocarina_logs"→ Par défaut, les logs vont dans .ocarina_logs/ à la racine du projet utilisateur. Gitignored par convention.
PrintLogger#
class PrintLogger(ILogger):
def __init__(self) -> None:
self._prefix_thunk: Thunk[str] = lambda: ""
self._domain_taxonomy: tuple[str, ...] = ("",)
def set_prefix(self, prefix_thunk: Thunk[str]) -> Self:
self._prefix_thunk = prefix_thunk
return self
def set_domain_taxonomy(self, taxonomy: tuple[str, ...]) -> Self:
self._domain_taxonomy = taxonomy
return self
def raw(self, *args, stream=None, **kwargs) -> None:
if stream is None:
stream = sys.stdout
print(*args, file=stream, **kwargs)
def info(self, msg, *args, exc=None, **kwargs):
self._print_log("[INFO]", msg, *args, exc=exc, stream=sys.stdout)
# ... id. pour critical / error / warning / debug / success / test_name ...- State :
_prefix_thunk(Thunk) +_domain_taxonomy(tuple). - Sortie :
sys.stdout(info, debug, success, test_name) ousys.stderr(critical, error, warning, exception). - Format :
<prefix> [LEVEL] <message>typiquement, avec ANSI colors si terminal. cleanup(): no-op.
FileLogger#
@final
class FileLogger(PrintLogger):
def __init__(
self, *,
base_dir: Path,
with_flush_effect: bool = True,
with_fallback_on_print_logger_when_no_taxonomy_effect: bool = True,
) -> None:
self._base_dir = base_dir
self._with_flush_effect = with_flush_effect
self._with_fallback_on_print_logger_when_no_taxonomy_effect = (
with_fallback_on_print_logger_when_no_taxonomy_effect
)
super().__init__()
@property
def _log_file_path(self) -> Path:
if len(self._domain_taxonomy) == 1:
folder_path = self._base_dir
file_name = f"{self._domain_taxonomy[0]}.log"
else:
folder_path = self._base_dir.joinpath(*self._domain_taxonomy[:-1])
file_name = f"{self._domain_taxonomy[-1]}.log"
folder_path.mkdir(parents=True, exist_ok=True)
return folder_path / file_name| Taxonomy | Folder | File |
|---|---|---|
("") | — | → Fallback PrintLogger si activé |
("annoncements",) | base_dir | annoncements.log |
("e2e", "Login") | base_dir/e2e | Login.log |
("e2e", "Login", "happy paths", "Login without OTP") | base_dir/e2e/Login/happy paths | Login without OTP.log |
La taxonomie devient l’arborescence. Les rapports DOCX (cf. 05-plugins-reports.md) parcourent cet arbre récursivement et fabriquent un .docx par cas de test.
PrintAndFileLogger#
class PrintAndFileLogger(FileLogger):
# … hérite de FileLogger mais imprime AUSSI dans stdout/stderr …C’est la combinaison par défaut (create_matching_logger("terminal+file")). Logs visibles en CLI pour la lecture humaine pendant l’exécution, et écrits sur disque pour les rapports.
MutedLogger#
class MutedLogger(ILogger):
def set_prefix(self, prefix_thunk): return self
def set_domain_taxonomy(self, taxonomy): return self
def raw(self, *args, stream=None, **kwargs): pass
def info(self, msg, *args, exc=None, **kwargs): pass
# … etc, tout est pass …
def cleanup(self): pass- Tests internes du framework (on ne veut pas polluer la sortie).
match_pagesans logger explicite (_logger = MutedLogger() if logger is None else logger).- Benchmarks où le logging fausserait la mesure.
set_prefix, Thunk, format_metadata_str#
# src/ocarina/opinionated/loggers/utils/format_metadata_str.py
def _format_metadata_str(*, namespace: str, metadata: str) -> str:
return "[" + namespace + "::" + metadata + "]"
def format_utc_date_metadata_str() -> str:
return _format_metadata_str(
namespace="UTC_DATE",
metadata=datetime.now(UTC).isoformat(),
)
def format_current_thread_metadata_str() -> str:
return _format_metadata_str(namespace="THREAD", metadata=current_thread().name)
def concat_metadata(*formatters: Thunk[str]) -> str:
...[UTC_DATE::2026-05-18T09:42:31.123456+00:00]
[THREAD::ThreadPoolExecutor-0_2]Le [UTC_DATE::...] est consommé en aval par generate_docx_proof :
_DEFAULT_UTC_DATE_REGEX = re.compile(r"\[UTC_DATE::([^]]+)]")
def _replace_utc_date(line: str, *, utc_date_regex: re.Pattern[str]) -> str:
def _repl(m: re.Match[str]) -> str:
return (
datetime.fromisoformat(m.group(1).replace("Z", "+00:00"))
.astimezone()
.strftime("[%m/%d/%Y | %Hh%M:%S.%f]")
)
return utc_date_regex.sub(_repl, line)Le DOCX traduit chaque [UTC_DATE::...] en date locale formatée. C’est un contrat de format entre le logger et le plugin DOCX.
set_prefix :
exceptions_logger = PrintLogger()
.set_prefix(
lambda: concat_metadata(
format_utc_date_metadata_str,
format_current_thread_metadata_str,
)
)
.set_domain_taxonomy(("Post-execution plugins",))Chaque log a son préfixe [UTC_DATE::...][THREAD::...], recalculé paresseusement à chaque appel.
cleanup()#
def cleanup(self) -> None:
# Flush effect (optionally print to stdout)
# Close file handle
# Recycle for next attempt
...Appelé par TestFlow.run avant un retry (cf. ../05-orchestration/03-test-flow-retries.md). Nouveau fichier fichier frais (même path, mais réécrit).
with_flush_effect#
Si True (default) : à cleanup(), le contenu du fichier est aussi imprimé en stdout. Utile pour voir ce qui s’est passé dans la tentative qui vient d’échouer (avant de la jeter pour repartir sur une nouvelle).
Si False : silent, juste close + recycle.
Fallback sur PrintLogger lorsque la taxonomie est vide#
Si with_fallback_on_print_logger_when_no_taxonomy_effect vaut True (default) : un FileLogger sans taxonomy explicite (c’est-à-dire ("") ou rien) fallback en PrintLogger. Évite d’écrire dans un fichier .log à la racine de base_dir quand un appelant a oublié de scoper.