Coverage for src/local_deep_research/config/thread_settings.py: 98%
69 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""Shared thread-local storage for settings context
3This module provides a single thread-local storage instance that can be
4shared across different modules to maintain settings context in threads.
5"""
7import threading
8from contextlib import contextmanager
10from ..settings.manager import get_typed_setting_value, is_valid_setting_key
11from ..utilities.type_utils import to_bool
14class NoSettingsContextError(Exception):
15 """Raised when settings context is not available in a thread."""
17 pass
20# Shared thread-local storage for settings context
21_thread_local = threading.local()
23# Sentinel distinguishing "key absent from snapshot" from "key present with
24# value None". Using None for both collapses legitimately-stored null values
25# (e.g. embeddings.openai.dimensions, which defaults to JSON null) into the
26# "not found" path, raising NoSettingsContextError in Flask request threads
27# that have no thread-local context. See #4208.
28_NOT_FOUND = object()
31def set_settings_context(settings_context):
32 """Set a settings context for the current thread."""
33 _thread_local.settings_context = settings_context
36def clear_settings_context():
37 """Clear the settings context for the current thread.
39 Should be called in a finally block after set_settings_context() to prevent
40 context from leaking to subsequent tasks when threads are reused in a pool.
41 """
42 if hasattr(_thread_local, "settings_context"):
43 del _thread_local.settings_context
46def get_settings_context():
47 """Get the settings context for the current thread."""
48 if hasattr(_thread_local, "settings_context"):
49 return _thread_local.settings_context
50 return None
53@contextmanager
54def settings_context(ctx):
55 """Context manager that sets and clears settings context automatically.
57 Ensures cleanup even if an exception occurs, preventing context leaks
58 when threads are reused in a pool.
60 Example:
61 with settings_context(my_settings):
62 run_research()
63 """
64 set_settings_context(ctx)
65 try:
66 yield
67 finally:
68 clear_settings_context()
71def get_setting_from_snapshot(
72 key,
73 default=None,
74 username=None,
75 settings_snapshot=None,
76):
77 """Get setting from context only - no database access from threads.
79 Args:
80 key: Setting key to retrieve
81 default: Default value if setting not found
82 username: Username (unused, kept for backward compatibility)
83 settings_snapshot: Optional settings snapshot dict
85 Returns:
86 Setting value or default
88 Raises:
89 RuntimeError: If no settings context is available
90 """
91 # First check if we have settings_snapshot passed directly.
92 # _NOT_FOUND (not None) is the absence sentinel so a key whose stored
93 # value is None is still treated as found. See #4208.
94 value = _NOT_FOUND
95 if settings_snapshot and key in settings_snapshot:
96 raw = settings_snapshot[key]
97 # Handle both full format {"value": x} and simplified format (just x)
98 if isinstance(raw, dict) and "value" in raw:
99 value = get_typed_setting_value(
100 key,
101 raw["value"],
102 raw.get("ui_element", "text"),
103 )
104 else:
105 value = raw
106 # Search for child keys.
107 elif settings_snapshot:
108 for full_key, v in settings_snapshot.items():
109 if not full_key.startswith(f"{key}."):
110 continue
111 # Skip malformed rows (e.g. legacy "foo." / "foo..") so a stray
112 # legacy key can't wrap a leaf read into an [object Object] dict
113 # (#4840) — mirrors SettingsManager.get_setting's filter on the DB
114 # read path.
115 if not is_valid_setting_key(full_key):
116 continue
117 child = full_key.removeprefix(f"{key}.")
118 # Handle both full format {"value": x} and simplified format (just x)
119 if isinstance(v, dict) and "value" in v:
120 v = get_typed_setting_value(
121 child, v["value"], v.get("ui_element", "text")
122 )
123 # else: v is already the raw value from simplified snapshot
124 if value is _NOT_FOUND:
125 value = {child: v}
126 else:
127 value[child] = v
129 if value is not _NOT_FOUND:
130 # Extract value from dict structure if needed
131 return value
133 # Check if we have a settings context in this thread
134 if (
135 hasattr(_thread_local, "settings_context")
136 and _thread_local.settings_context
137 ):
138 value = _thread_local.settings_context.get_setting(key, default)
139 # Extract value from dict structure if needed (same as above)
140 if isinstance(value, dict) and "value" in value:
141 return value["value"]
142 return value
144 # If a default was provided, return it instead of raising an exception
145 if default is not None:
146 from loguru import logger
148 logger.debug(
149 f"Setting '{key}' not found in snapshot or context, using default"
150 )
151 return default
153 # Only raise the exception if no default was provided
154 raise NoSettingsContextError(
155 f"No settings context available in thread for key '{key}'. All settings must be passed via settings_snapshot."
156 )
159def get_bool_setting_from_snapshot(
160 key,
161 default=False,
162 username=None,
163 settings_snapshot=None,
164):
165 """Get a boolean setting from snapshot, handling string conversion.
167 This centralizes the string-to-boolean conversion logic for settings
168 retrieved from snapshots. Handles various truthy string representations
169 that may come from API requests, config files, or SQLite.
171 Args:
172 key: Setting key to retrieve
173 default: Default boolean value if setting not found
174 username: Username (unused, kept for backward compatibility)
175 settings_snapshot: Optional settings snapshot dict
177 Returns:
178 Boolean value of the setting
179 """
180 value = get_setting_from_snapshot(
181 key,
182 default,
183 username=username,
184 settings_snapshot=settings_snapshot,
185 )
187 return to_bool(value, default)
190def _get_optional_setting(
191 param_dict,
192 param_name,
193 setting_key,
194 settings_snapshot,
195 *,
196 cast=None,
197 check="not_none",
198):
199 """Fetch a setting and, when present, write it into ``param_dict``.
201 Consolidates the recurring
202 ``try: get_setting_from_snapshot(...) ... except NoSettingsContextError: pass``
203 pattern used by the LLM provider ``create_llm`` factories.
204 ``NoSettingsContextError`` is silently swallowed because these
205 parameters are always optional.
207 Args:
208 param_dict: Target dict (e.g. ``llm_params``) mutated in place.
209 param_name: Key to set on ``param_dict`` when a value is present.
210 setting_key: Dotted settings path to retrieve.
211 settings_snapshot: Snapshot dict passed through to
212 :func:`get_setting_from_snapshot`.
213 cast: Optional callable applied to the value before assignment
214 (e.g. ``int`` for ``max_tokens``).
215 check: ``"not_none"`` (default) writes the value when it is not
216 ``None``. ``"falsy"`` writes only when the value is truthy.
217 The falsy mode is an intentional safeguard for fields where
218 a falsy stored value (``organization=""``) must be dropped
219 rather than forwarded.
221 The ``max_tokens`` blocks that resolve their value through
222 ``compute_max_tokens`` / ``get_context_window_for_provider`` (rather
223 than a single settings lookup) do not fit this helper and are left
224 in place. The ``api_base`` block in ``openai.py`` is also left in
225 place because it wraps an SSRF validation side effect.
226 """
227 try:
228 value = get_setting_from_snapshot(
229 setting_key,
230 default=None,
231 settings_snapshot=settings_snapshot,
232 )
233 except NoSettingsContextError:
234 return
235 if check == "not_none":
236 if value is not None:
237 param_dict[param_name] = cast(value) if cast else value
238 elif check == "falsy": 238 ↛ 242line 238 didn't jump to line 242 because the condition on line 238 was always true
239 if value:
240 param_dict[param_name] = cast(value) if cast else value
241 else:
242 raise ValueError(f"Unknown check mode: {check!r}")