Coverage for src/ph/logging_config.py: 100.0%
49 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Application logging that stays out of the curses terminal."""
3import logging
4from logging.handlers import RotatingFileHandler
5from pathlib import Path
7LOGGER_NAME = "ph"
8DEFAULT_LOG_LEVEL = "INFO"
9MAX_LOG_BYTES = 1024 * 1024
10LOG_BACKUP_COUNT = 2
13def configure_logging(log_file: Path | None, level_name: str = DEFAULT_LOG_LEVEL) -> logging.Logger:
14 """Configure one package logger, writing only to a file when one is requested."""
16 return configure_logging_with_fallback(log_file, level_name)
19def configure_logging_with_fallback(
20 log_file: Path | None,
21 level_name: str = DEFAULT_LOG_LEVEL,
22 fallback_file: Path | None = None,
23) -> logging.Logger:
24 """Configure logging and use a writable fallback when the preferred path fails."""
26 logger = logging.getLogger(LOGGER_NAME)
27 for existing_handler in logger.handlers:
28 existing_handler.close()
29 logger.handlers.clear()
30 logger.propagate = False
31 level = getattr(logging, level_name.upper(), logging.INFO)
32 logger.setLevel(level)
34 if log_file is None:
35 logger.addHandler(logging.NullHandler())
36 return logger
38 handler: logging.Handler | None = None
39 failures: list[tuple[Path, OSError]] = []
40 candidates = tuple(
41 dict.fromkeys(path for path in (log_file, fallback_file) if path is not None)
42 )
43 for candidate in candidates:
44 try:
45 candidate.parent.mkdir(parents=True, exist_ok=True)
46 handler = RotatingFileHandler(
47 candidate,
48 maxBytes=MAX_LOG_BYTES,
49 backupCount=LOG_BACKUP_COUNT,
50 encoding="utf-8",
51 )
52 except OSError as error:
53 failures.append((candidate, error))
54 continue
55 break
56 if handler is None:
57 handler = logging.NullHandler()
58 handler.setLevel(level)
59 handler.setFormatter(
60 logging.Formatter(
61 "%(asctime)s %(levelname)s %(name)s: %(message)s",
62 datefmt="%Y-%m-%dT%H:%M:%S%z",
63 )
64 )
65 logger.addHandler(handler)
66 active_file = active_log_file(logger)
67 if active_file is not None:
68 if failures:
69 failed_path, error = failures[0]
70 logger.warning(
71 "Could not open requested log path=%s error=%s; using fallback=%s",
72 failed_path,
73 error,
74 active_file,
75 )
76 logger.info(
77 "File logging active path=%s level=%s", active_file, logging.getLevelName(level)
78 )
79 return logger
82def active_log_file(logger: logging.Logger | None = None) -> Path | None:
83 """Return the absolute path used by the package file handler, if enabled."""
85 selected = logger or logging.getLogger(LOGGER_NAME)
86 for handler in selected.handlers:
87 if isinstance(handler, logging.FileHandler):
88 return Path(handler.baseFilename)
89 return None