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

1"""Application logging that stays out of the curses terminal.""" 

2 

3import logging 

4from logging.handlers import RotatingFileHandler 

5from pathlib import Path 

6 

7LOGGER_NAME = "ph" 

8DEFAULT_LOG_LEVEL = "INFO" 

9MAX_LOG_BYTES = 1024 * 1024 

10LOG_BACKUP_COUNT = 2 

11 

12 

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.""" 

15 

16 return configure_logging_with_fallback(log_file, level_name) 

17 

18 

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.""" 

25 

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) 

33 

34 if log_file is None: 

35 logger.addHandler(logging.NullHandler()) 

36 return logger 

37 

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 

80 

81 

82def active_log_file(logger: logging.Logger | None = None) -> Path | None: 

83 """Return the absolute path used by the package file handler, if enabled.""" 

84 

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