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
« 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.
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"""
12import os
13import re
14from collections.abc import Mapping
15from typing import Any, Dict, Optional
16from loguru import logger
19# Check environment variable once at module load
20SETTINGS_LOG_LEVEL = os.getenv("LDR_LOG_SETTINGS", "none").lower()
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
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
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"
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.
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)
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
76 if log_level == "none":
77 return
79 if log_level == "summary":
80 # Log only summary at INFO level
81 summary = create_settings_summary(settings)
82 logger.info(f"{message}: {summary}")
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 )
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())
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)
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)
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 )
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
195 return redact_url_credentials(value)
196 return value
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)
205def redact_sensitive_keys(settings: Dict[str, Any]) -> Dict[str, Any]:
206 """
207 Redact sensitive keys from settings dictionary.
209 Args:
210 settings: Settings dictionary
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
231 return redacted
234def create_settings_summary(settings: Any) -> str:
235 """
236 Create a summary of settings for logging.
238 Args:
239 settings: Settings object or dict
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)
250 return f"{total} total settings (search engines: {search_engines}, LLM: {llm_settings})"
251 return f"Settings object of type {type(settings).__name__}"
254def get_settings_log_level() -> str:
255 """
256 Get the current settings logging level.
258 Returns:
259 Current log level: "none", "summary", or "debug"
260 """
261 return SETTINGS_LOG_LEVEL