Coverage for session_buddy / utils / logging.py: 83.20%
93 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
1#!/usr/bin/env python3
2"""Structured logging utilities for session management."""
4from __future__ import annotations
6import json
7import logging
8import sys
9import typing as t
10from contextlib import suppress
11from datetime import datetime
12from pathlib import Path
14from session_buddy.di import get_sync_typed
15from session_buddy.di.container import depends
18class SessionLogger:
19 """Structured logging for session management with context."""
21 def __init__(self, log_dir: Path) -> None:
22 self.log_dir = log_dir
23 self.log_dir.mkdir(parents=True, exist_ok=True)
24 self.log_file = (
25 log_dir / f"session_management_{datetime.now().strftime('%Y%m%d')}.log"
26 )
28 # Configure logger
29 self.logger = logging.getLogger("session_management")
30 self.logger.setLevel(logging.INFO)
32 formatter = logging.Formatter(
33 "%(asctime)s | %(levelname)s | %(funcName)s:%(lineno)d | %(message)s",
34 )
36 # Ensure console handler exists with correct settings
37 console_handler = _get_console_handler(self.logger)
38 if console_handler is None:
39 console_handler = logging.StreamHandler(sys.stderr)
40 self.logger.addHandler(console_handler)
41 console_handler.setLevel(logging.ERROR)
42 console_handler.setFormatter(formatter)
44 # Ensure file handler for this log directory exists
45 file_handler = _get_file_handler(self.logger, self.log_file)
46 if file_handler is None:
47 file_handler = logging.FileHandler(self.log_file)
48 self.logger.addHandler(file_handler)
49 file_handler.setLevel(logging.INFO)
50 file_handler.setFormatter(formatter)
52 def info(self, message: str, **context: t.Any) -> None:
53 """Log info with optional context."""
54 if context:
55 message = f"{message} | Context: {_safe_json_serialize(context)}"
56 self.logger.info(message)
58 def warning(self, message: str, **context: t.Any) -> None:
59 """Log warning with optional context."""
60 if context:
61 message = f"{message} | Context: {_safe_json_serialize(context)}"
62 self.logger.warning(message)
64 def error(self, message: str, **context: t.Any) -> None:
65 """Log error with optional context."""
66 if context:
67 message = f"{message} | Context: {_safe_json_serialize(context)}"
68 self.logger.error(message)
70 def debug(self, message: str, **context: t.Any) -> None:
71 """Log debug with optional context."""
72 if context: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 message = f"{message} | Context: {_safe_json_serialize(context)}"
74 self.logger.debug(message)
76 def exception(self, message: str, **context: t.Any) -> None:
77 """Log exception with optional context."""
78 if context: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 message = f"{message} | Context: {_safe_json_serialize(context)}"
80 self.logger.error(message)
82 def critical(self, message: str, **context: t.Any) -> None:
83 """Log critical with optional context."""
84 if context: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 message = f"{message} | Context: {_safe_json_serialize(context)}"
86 self.logger.critical(message)
89def get_session_logger() -> SessionLogger:
90 """Get the global session logger instance managed by the DI container."""
91 with suppress(KeyError, AttributeError, RuntimeError, TypeError):
92 # RuntimeError: when adapter requires async
93 # TypeError: when DI has type confusion between string keys and classes
94 logger = get_sync_typed(SessionLogger) # type: ignore[no-any-return]
95 if isinstance(logger, SessionLogger): 95 ↛ 98line 95 didn't jump to line 98
96 return logger
98 logger = SessionLogger(_resolve_logs_dir())
99 depends.set(SessionLogger, logger)
100 return logger
103def _resolve_logs_dir() -> Path:
104 # Try to get SessionPaths from DI (modern ACB pattern)
105 with suppress(KeyError, AttributeError, RuntimeError, TypeError):
106 from session_buddy.di.config import SessionPaths
108 paths = depends.get_sync(SessionPaths)
109 if hasattr(paths, "logs_dir") and isinstance(paths.logs_dir, Path):
110 paths.logs_dir.mkdir(parents=True, exist_ok=True)
111 return paths.logs_dir
113 # Fallback: create default logs directory
114 logs_dir = Path.home() / ".claude" / "logs"
115 logs_dir.mkdir(parents=True, exist_ok=True)
116 return logs_dir
119def _get_console_handler(
120 logger: logging.Logger,
121) -> logging.StreamHandler[t.TextIO] | None:
122 for handler in logger.handlers:
123 if isinstance(handler, logging.StreamHandler) and not isinstance( 123 ↛ 122line 123 didn't jump to line 122 because the condition on line 123 was always true
124 handler,
125 logging.FileHandler,
126 ):
127 return handler
128 return None
131def _get_file_handler(
132 logger: logging.Logger,
133 log_file: Path,
134) -> logging.FileHandler | None:
135 for handler in logger.handlers:
136 if isinstance(handler, logging.FileHandler):
137 try:
138 if Path(handler.baseFilename) == log_file:
139 return handler
140 except Exception:
141 continue
142 return None
145def _safe_json_serialize(obj: t.Any) -> str:
146 """Safely serialize objects to JSON, converting non-serializable objects to strings."""
147 try:
148 return json.dumps(obj)
149 except (TypeError, ValueError):
150 # Convert non-serializable objects to string representation
151 if isinstance(obj, dict):
152 return json.dumps(
153 {
154 # Use explicit None check for better type inference (refurb FURB168)
155 k: str(v)
156 if v is not None and not isinstance(v, (str, int, float, bool))
157 else v
158 for k, v in obj.items()
159 },
160 )
161 return json.dumps(str(obj))