Coverage for src/local_deep_research/settings/logger.py: 94%
55 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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
13from typing import Any, Dict, Optional
14from loguru import logger
17# Check environment variable once at module load
18SETTINGS_LOG_LEVEL = os.getenv("LDR_LOG_SETTINGS", "none").lower()
20# Map various values to standardized levels
21if SETTINGS_LOG_LEVEL in ("false", "0", "no", "none", "off"):
22 SETTINGS_LOG_LEVEL = "none"
23elif SETTINGS_LOG_LEVEL in ("true", "1", "yes", "info", "summary"):
24 SETTINGS_LOG_LEVEL = "summary"
25elif SETTINGS_LOG_LEVEL in ("debug", "full", "all"):
26 SETTINGS_LOG_LEVEL = "debug"
27elif SETTINGS_LOG_LEVEL in ("debug_unsafe", "unsafe", "raw"):
28 import warnings
30 warnings.warn(
31 f"LDR_LOG_SETTINGS={os.getenv('LDR_LOG_SETTINGS')!r} is deprecated and has been "
32 "removed for security. Use 'debug' for full settings with sensitive keys redacted. "
33 "Defaulting to 'none'.",
34 DeprecationWarning,
35 stacklevel=2,
36 )
37 # logger.warning() won't work here — loguru is disabled at module load time
38 # (see __init__.py). Write directly to stderr so users actually see this.
39 import sys
41 print(
42 f"WARNING: LDR_LOG_SETTINGS={os.getenv('LDR_LOG_SETTINGS')!r} is deprecated and "
43 "has been removed for security. Use 'debug' for full settings with sensitive keys "
44 "redacted. Defaulting to 'none'.",
45 file=sys.stderr,
46 )
47 SETTINGS_LOG_LEVEL = "none"
48else:
49 # Invalid value, default to none
50 SETTINGS_LOG_LEVEL = "none"
53def log_settings(
54 settings: Any,
55 message: str = "Settings loaded",
56 force_level: Optional[str] = None,
57) -> None:
58 """
59 Centralized settings logging with conditional output based on LDR_LOG_SETTINGS env var.
61 Args:
62 settings: Settings object or dict to log
63 message: Log message prefix
64 force_level: Override the environment variable setting (for critical messages)
66 Behavior based on LDR_LOG_SETTINGS:
67 - "none": No output
68 - "summary": Log count and basic info at INFO level
69 - "debug": Log full settings at DEBUG level (sensitive keys redacted)
70 - "debug_unsafe"/"unsafe"/"raw": REMOVED — maps to "none" with a deprecation warning
71 """
72 log_level = force_level or SETTINGS_LOG_LEVEL
74 if log_level == "none":
75 return
77 if log_level == "summary":
78 # Log only summary at INFO level
79 summary = create_settings_summary(settings)
80 logger.info(f"{message}: {summary}")
82 elif log_level == "debug": 82 ↛ exitline 82 didn't return from function 'log_settings' because the condition on line 82 was always true
83 # Log full settings at DEBUG level with redaction
84 if isinstance(settings, dict): 84 ↛ 88line 84 didn't jump to line 88 because the condition on line 84 was always true
85 safe_settings = redact_sensitive_keys(settings)
86 logger.debug(f"{message} (redacted): {safe_settings}")
87 else:
88 logger.debug(f"{message}: {settings}")
91_SENSITIVE_KEY_PATTERNS = (
92 "api_key",
93 "apikey",
94 "password",
95 "secret",
96 "token",
97 "credential",
98 "auth",
99 "private",
100 # Ported from #5602. main added this to a function-local list in
101 # redact_sensitive_keys(); this branch had already refactored that list
102 # into this module-level tuple, so taking "ours" would have silently
103 # dropped the fix. The apprise-style notification URL embeds credentials
104 # (mailto://user:pass@host, discord://webhook_id/token), so it must be
105 # redacted wherever settings are rendered.
106 "service_url",
107)
110def is_sensitive_setting_key(key: str) -> bool:
111 """Return True if the setting key should be redacted in responses."""
112 key_lower = key.lower()
113 return any(p in key_lower for p in _SENSITIVE_KEY_PATTERNS)
116def redact_sensitive_keys(settings: Dict[str, Any]) -> Dict[str, Any]:
117 """
118 Redact sensitive keys from settings dictionary.
120 Args:
121 settings: Settings dictionary
123 Returns:
124 Settings dictionary with sensitive values redacted
125 """
126 redacted = {}
127 for key, value in settings.items():
128 if is_sensitive_setting_key(key):
129 # Redact the value
130 if isinstance(value, dict) and "value" in value:
131 redacted[key] = {**value, "value": "***REDACTED***"}
132 elif isinstance(value, str): 132 ↛ 135line 132 didn't jump to line 135 because the condition on line 132 was always true
133 redacted[key] = "***REDACTED***"
134 else:
135 redacted[key] = "***REDACTED***"
136 elif isinstance(value, dict):
137 # Recursively redact nested dicts
138 redacted[key] = redact_sensitive_keys(value)
139 else:
140 redacted[key] = value
142 return redacted
145def create_settings_summary(settings: Any) -> str:
146 """
147 Create a summary of settings for logging.
149 Args:
150 settings: Settings object or dict
152 Returns:
153 Summary string
154 """
155 if isinstance(settings, dict):
156 # Count different types of settings
157 search_engines = sum(1 for k in settings.keys() if "search.engine" in k)
158 llm_settings = sum(1 for k in settings.keys() if "llm." in k)
159 total = len(settings)
161 return f"{total} total settings (search engines: {search_engines}, LLM: {llm_settings})"
162 return f"Settings object of type {type(settings).__name__}"
165def get_settings_log_level() -> str:
166 """
167 Get the current settings logging level.
169 Returns:
170 Current log level: "none", "summary", or "debug"
171 """
172 return SETTINGS_LOG_LEVEL