Coverage for src/local_deep_research/settings/logger.py: 96%

87 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Centralized utility for logging settings and configuration. 

3Controls when and how settings are logged based on environment variables. 

4 

5Environment variable LDR_LOG_SETTINGS controls the verbosity: 

6- "none" or "false": No settings logging at all (default) 

7- "summary" or "info": Only log count and summary of settings 

8- "debug" or "full": Log complete settings (with sensitive keys redacted) 

9- "debug_unsafe"/"unsafe"/"raw": REMOVED for security — maps to "none" with a deprecation warning 

10""" 

11 

12import os 

13import re 

14from collections.abc import Mapping 

15from typing import Any, Dict, Optional 

16from loguru import logger 

17 

18 

19# Check environment variable once at module load 

20SETTINGS_LOG_LEVEL = os.getenv("LDR_LOG_SETTINGS", "none").lower() 

21 

22# Map various values to standardized levels 

23if SETTINGS_LOG_LEVEL in ("false", "0", "no", "none", "off"): 

24 SETTINGS_LOG_LEVEL = "none" 

25elif SETTINGS_LOG_LEVEL in ("true", "1", "yes", "info", "summary"): 

26 SETTINGS_LOG_LEVEL = "summary" 

27elif SETTINGS_LOG_LEVEL in ("debug", "full", "all"): 

28 SETTINGS_LOG_LEVEL = "debug" 

29elif SETTINGS_LOG_LEVEL in ("debug_unsafe", "unsafe", "raw"): 

30 import warnings 

31 

32 warnings.warn( 

33 f"LDR_LOG_SETTINGS={os.getenv('LDR_LOG_SETTINGS')!r} is deprecated and has been " 

34 "removed for security. Use 'debug' for full settings with sensitive keys redacted. " 

35 "Defaulting to 'none'.", 

36 DeprecationWarning, 

37 stacklevel=2, 

38 ) 

39 # logger.warning() won't work here — loguru is disabled at module load time 

40 # (see __init__.py). Write directly to stderr so users actually see this. 

41 import sys 

42 

43 print( 

44 f"WARNING: LDR_LOG_SETTINGS={os.getenv('LDR_LOG_SETTINGS')!r} is deprecated and " 

45 "has been removed for security. Use 'debug' for full settings with sensitive keys " 

46 "redacted. Defaulting to 'none'.", 

47 file=sys.stderr, 

48 ) 

49 SETTINGS_LOG_LEVEL = "none" 

50else: 

51 # Invalid value, default to none 

52 SETTINGS_LOG_LEVEL = "none" 

53 

54 

55def log_settings( 

56 settings: Any, 

57 message: str = "Settings loaded", 

58 force_level: Optional[str] = None, 

59) -> None: 

60 """ 

61 Centralized settings logging with conditional output based on LDR_LOG_SETTINGS env var. 

62 

63 Args: 

64 settings: Settings object or dict to log 

65 message: Log message prefix 

66 force_level: Override the environment variable setting (for critical messages) 

67 

68 Behavior based on LDR_LOG_SETTINGS: 

69 - "none": No output 

70 - "summary": Log count and basic info at INFO level 

71 - "debug": Log full settings at DEBUG level (sensitive keys redacted) 

72 - "debug_unsafe"/"unsafe"/"raw": REMOVED — maps to "none" with a deprecation warning 

73 """ 

74 log_level = force_level or SETTINGS_LOG_LEVEL 

75 

76 if log_level == "none": 

77 return 

78 

79 if log_level == "summary": 

80 # Log only summary at INFO level 

81 summary = create_settings_summary(settings) 

82 logger.info(f"{message}: {summary}") 

83 

84 elif log_level == "debug": 84 ↛ exitline 84 didn't return from function 'log_settings' because the condition on line 84 was always true

85 # Log full settings at DEBUG level with redaction 

86 if isinstance(settings, dict): 

87 safe_settings = _redact_url_values(redact_sensitive_keys(settings)) 

88 logger.debug(f"{message} (redacted): {safe_settings}") 

89 else: 

90 # Non-dict settings get the same redaction: coerce to a dict 

91 # (mapping, pydantic model_dump, or instance attrs) so 

92 # redact_sensitive_keys can mask secret-bearing keys. An 

93 # unredactable shape logs the type name ONLY — never a raw 

94 # repr, which would bypass the redaction this level promises 

95 # for both shapes (see the docstring above). 

96 def _unredactable_shape_message() -> str: 

97 return ( 

98 f"{message}: <{type(settings).__name__} not logged — " 

99 f"unredactable shape>" 

100 ) 

101 

102 # The whole coercion (including Mapping iteration, model_dump(), 

103 # and vars()/__dict__ access) AND the redaction/formatting run 

104 # inside one try: any of these can raise on an adversarial or 

105 # malformed object (e.g. a Mapping with a raising __iter__, a 

106 # __dict__ property that raises, or a coerced dict with 

107 # non-string keys that break is_sensitive_setting_key()). On any 

108 # exception we fall back to the type name only — never a raw 

109 # repr or partially-redacted value. 

110 try: 

111 coerced = None 

112 if isinstance(settings, Mapping): 

113 coerced = dict(settings) 

114 elif hasattr(settings, "model_dump"): 

115 coerced = dict(settings.model_dump()) 

116 elif hasattr(settings, "__dict__"): 

117 coerced = dict(vars(settings)) 

118 if coerced is not None: 

119 safe_coerced = _redact_url_values( 

120 redact_sensitive_keys(coerced) 

121 ) 

122 logger.debug(f"{message} (redacted): {safe_coerced}") 

123 else: 

124 logger.debug(_unredactable_shape_message()) 

125 except Exception: 

126 logger.debug(_unredactable_shape_message()) 

127 

128 

129_SENSITIVE_KEY_PATTERNS = ( 

130 "api_key", 

131 "apikey", 

132 "password", 

133 "secret", 

134 "token", 

135 "credential", 

136 "auth", 

137 "private", 

138 # Ported from #5602. main added this to a function-local list in 

139 # redact_sensitive_keys(); this branch had already refactored that list 

140 # into this module-level tuple, so taking "ours" would have silently 

141 # dropped the fix. The apprise-style notification URL embeds credentials 

142 # (mailto://user:pass@host, discord://webhook_id/token), so it must be 

143 # redacted wherever settings are rendered. 

144 "service_url", 

145) 

146 

147 

148# ``user:password@host...`` without a scheme (matched after leading 

149# whitespace is stripped). The user part -- possibly empty, as in 

150# ``:TOKEN@host/v1`` -- has no whitespace, ``@``, ``:`` or ``/``. Two 

151# forms: 

152# * a password with no whitespace or ``@`` up to the first ``@`` -- any 

153# host may follow (``ollama_server``, an IDN, ``[fe80::1%eth0]``); 

154# * a password that contains whitespace (a newline too) or ``@`` but does 

155# not START with whitespace, followed by a host-like part (no 

156# whitespace, ``@``, ``/``, ``?`` or ``#``) and then ``/``, ``?``, 

157# ``#``, whitespace or the end. 

158# A password must not start with whitespace, so prose such as 

159# ``"Note: mail admin@example.com"`` is not mistaken for userinfo. Known 

160# limits: a password that begins with whitespace is not recognised, and 

161# neither is a token-only ``TOKEN@host`` (no ``:``). The pattern is 

162# anchored and its backtracking is linear in the value's length. 

163_SCHEMELESS_USERINFO_RE = re.compile( 

164 r"^[^\s@:/]*:(?:[^\s@]*@|[^\s@][\s\S]*@[^\s@/?#]+(?:[/?#\s]|$))" 

165) 

166 

167 

168def _looks_like_url(value: str) -> bool: 

169 """Whether a setting value should go through the URL redactor: it 

170 contains ``://``, starts with scheme-less userinfo, or is a single 

171 whitespace-free token (surrounding whitespace ignored) with a query or 

172 fragment (a scheme-less ``localhost:1234/v1?api_key=...``).""" 

173 token = value.strip() 

174 if "://" in token or _SCHEMELESS_USERINFO_RE.match(token): 

175 return True 

176 return ("?" in token or "#" in token) and not any( 

177 c.isspace() for c in token 

178 ) 

179 

180 

181def _redact_url_values(value: Any) -> Any: 

182 """Redact credentials embedded in URL-valued settings (e.g. an 

183 ``llm.openai_endpoint.url`` of ``https://host/v1?api_key=...``) for the 

184 debug settings log. Key-name redaction alone misses them because the 

185 setting's key (``*.url``) is not sensitive. See ``_looks_like_url`` 

186 for which strings are treated as URLs.""" 

187 if isinstance(value, dict): 

188 return {k: _redact_url_values(v) for k, v in value.items()} 

189 if isinstance(value, list): 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 return [_redact_url_values(v) for v in value] 

191 if isinstance(value, str) and _looks_like_url(value): 

192 # Lazy import: the security package imports settings modules. 

193 from ..security.url_redaction import redact_url_credentials 

194 

195 return redact_url_credentials(value) 

196 return value 

197 

198 

199def is_sensitive_setting_key(key: str) -> bool: 

200 """Return True if the setting key should be redacted in responses.""" 

201 key_lower = key.lower() 

202 return any(p in key_lower for p in _SENSITIVE_KEY_PATTERNS) 

203 

204 

205def redact_sensitive_keys(settings: Dict[str, Any]) -> Dict[str, Any]: 

206 """ 

207 Redact sensitive keys from settings dictionary. 

208 

209 Args: 

210 settings: Settings dictionary 

211 

212 Returns: 

213 Settings dictionary with sensitive values redacted 

214 """ 

215 redacted = {} 

216 for key, value in settings.items(): 

217 if is_sensitive_setting_key(key): 

218 # Redact the value 

219 if isinstance(value, dict) and "value" in value: 

220 redacted[key] = {**value, "value": "***REDACTED***"} 

221 elif isinstance(value, str): 221 ↛ 224line 221 didn't jump to line 224 because the condition on line 221 was always true

222 redacted[key] = "***REDACTED***" 

223 else: 

224 redacted[key] = "***REDACTED***" 

225 elif isinstance(value, dict): 

226 # Recursively redact nested dicts 

227 redacted[key] = redact_sensitive_keys(value) 

228 else: 

229 redacted[key] = value 

230 

231 return redacted 

232 

233 

234def create_settings_summary(settings: Any) -> str: 

235 """ 

236 Create a summary of settings for logging. 

237 

238 Args: 

239 settings: Settings object or dict 

240 

241 Returns: 

242 Summary string 

243 """ 

244 if isinstance(settings, dict): 

245 # Count different types of settings 

246 search_engines = sum(1 for k in settings.keys() if "search.engine" in k) 

247 llm_settings = sum(1 for k in settings.keys() if "llm." in k) 

248 total = len(settings) 

249 

250 return f"{total} total settings (search engines: {search_engines}, LLM: {llm_settings})" 

251 return f"Settings object of type {type(settings).__name__}" 

252 

253 

254def get_settings_log_level() -> str: 

255 """ 

256 Get the current settings logging level. 

257 

258 Returns: 

259 Current log level: "none", "summary", or "debug" 

260 """ 

261 return SETTINGS_LOG_LEVEL