Coverage for src/local_deep_research/config/thread_settings.py: 98%
77 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"""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()
30# Sentinel distinguishing "caller supplied no default" from "caller
31# explicitly wants None when the key is absent". Before #5984, None did
32# double duty for both, so the natural spelling of "this setting is
33# optional" — default=None — raised NoSettingsContextError instead of
34# returning None (the trap behind #5331 and #5766). Mirrors _NOT_FOUND
35# above for the lookup half of the same function.
36_UNSET = object()
39def set_settings_context(settings_context):
40 """Set a settings context for the current thread."""
41 _thread_local.settings_context = settings_context
44def clear_settings_context():
45 """Clear the settings context for the current thread.
47 Should be called in a finally block after set_settings_context() to prevent
48 context from leaking to subsequent tasks when threads are reused in a pool.
49 """
50 if hasattr(_thread_local, "settings_context"):
51 del _thread_local.settings_context
54def get_settings_context():
55 """Get the settings context for the current thread."""
56 if hasattr(_thread_local, "settings_context"):
57 return _thread_local.settings_context
58 return None
61@contextmanager
62def settings_context(ctx):
63 """Context manager that sets and clears settings context automatically.
65 Ensures cleanup even if an exception occurs, preventing context leaks
66 when threads are reused in a pool.
68 Example:
69 with settings_context(my_settings):
70 run_research()
71 """
72 set_settings_context(ctx)
73 try:
74 yield
75 finally:
76 clear_settings_context()
79def get_setting_from_snapshot(
80 key,
81 default=_UNSET,
82 username=None,
83 settings_snapshot=None,
84):
85 """Get setting from context only - no database access from threads.
87 Args:
88 key: Setting key to retrieve
89 default: Default value if setting not found. Pass ``None``
90 explicitly to make the setting optional — ``None`` is
91 returned when the key is absent. Omit ``default`` to
92 require a value: a missing key then raises, unless a thread
93 settings context is bound (its lookup result — ``None``
94 for a key it is missing — is returned instead).
95 username: Username (unused, kept for backward compatibility)
96 settings_snapshot: Optional settings snapshot dict
98 Returns:
99 Setting value or default
101 Raises:
102 NoSettingsContextError: If the key is not found in the snapshot,
103 no thread settings context is available, and no default was
104 provided
105 """
106 # First check if we have settings_snapshot passed directly.
107 # _NOT_FOUND (not None) is the absence sentinel so a key whose stored
108 # value is None is still treated as found. See #4208.
109 value = _NOT_FOUND
110 if settings_snapshot and key in settings_snapshot:
111 raw = settings_snapshot[key]
112 # Handle both full format {"value": x} and simplified format (just x)
113 if isinstance(raw, dict) and "value" in raw:
114 value = get_typed_setting_value(
115 key,
116 raw["value"],
117 raw.get("ui_element", "text"),
118 )
119 else:
120 value = raw
121 # Search for child keys.
122 elif settings_snapshot:
123 for full_key, v in settings_snapshot.items():
124 if not full_key.startswith(f"{key}."):
125 continue
126 # Skip malformed rows (e.g. legacy "foo." / "foo..") so a stray
127 # legacy key can't wrap a leaf read into an [object Object] dict
128 # (#4840) — mirrors SettingsManager.get_setting's filter on the DB
129 # read path.
130 if not is_valid_setting_key(full_key):
131 continue
132 child = full_key.removeprefix(f"{key}.")
133 # Handle both full format {"value": x} and simplified format (just x)
134 if isinstance(v, dict) and "value" in v:
135 v = get_typed_setting_value(
136 child, v["value"], v.get("ui_element", "text")
137 )
138 # else: v is already the raw value from simplified snapshot
139 if value is _NOT_FOUND:
140 value = {child: v}
141 else:
142 value[child] = v
144 if value is not _NOT_FOUND:
145 # Extract value from dict structure if needed
146 return value
148 # Check if we have a settings context in this thread.
149 #
150 # This storage is a bare ``threading.local()``, so on a POOLED thread it
151 # outlives the task that set it. If a future caller ever sets a context
152 # from a handler offloaded onto the shared worker pool (anyio/
153 # ``asyncio.to_thread``), the next task landing on that thread would read
154 # the previous user's value. Every current ``set_settings_context`` call
155 # site runs on a dedicated thread or clears in a ``finally``, so this is
156 # not reachable today — but the failure mode is a silent cross-user
157 # value, so validate identity rather than relying on that staying true.
158 #
159 # Mirrors the self-heal that already protects
160 # ``ThreadLocalSessionManager.get_session``: on mismatch treat the
161 # context as absent and fall through to the default.
162 ctx = getattr(_thread_local, "settings_context", None)
163 if ctx:
164 ctx_username = getattr(ctx, "username", None)
165 from ..utilities.request_context import get_current_username
167 current_username = get_current_username()
168 if (
169 ctx_username is not None
170 and current_username is not None
171 and ctx_username != current_username
172 ):
173 from loguru import logger
175 logger.warning(
176 "Discarding stale thread-local settings context for "
177 f"'{key}': built for user {ctx_username!r} but the current "
178 f"request is {current_username!r}. Falling through to the "
179 "default rather than serving another user's value."
180 )
181 else:
182 # Never forward _UNSET: the context's get_setting would return
183 # the sentinel itself for a missing key, leaking it to callers.
184 value = ctx.get_setting(key, None if default is _UNSET else default)
185 # Extract value from dict structure if needed (same as above)
186 if isinstance(value, dict) and "value" in value:
187 return value["value"]
188 return value
190 # If a default was provided, return it — including an explicit None,
191 # which reads as "optional" (#5984). _UNSET, not None, means "no
192 # default was supplied" and falls through to the raise below.
193 if default is not _UNSET:
194 from loguru import logger
196 logger.debug(
197 f"Setting '{key}' not found in snapshot or context, using default"
198 )
199 return default
201 # Only raise the exception if no default was provided
202 raise NoSettingsContextError(
203 f"No settings context available in thread for key '{key}'. All settings must be passed via settings_snapshot."
204 )
207def get_bool_setting_from_snapshot(
208 key,
209 default=False,
210 username=None,
211 settings_snapshot=None,
212):
213 """Get a boolean setting from snapshot, handling string conversion.
215 This centralizes the string-to-boolean conversion logic for settings
216 retrieved from snapshots. Handles various truthy string representations
217 that may come from API requests, config files, or SQLite.
219 Args:
220 key: Setting key to retrieve
221 default: Default boolean value if setting not found
222 username: Username (unused, kept for backward compatibility)
223 settings_snapshot: Optional settings snapshot dict
225 Returns:
226 Boolean value of the setting
227 """
228 value = get_setting_from_snapshot(
229 key,
230 default,
231 username=username,
232 settings_snapshot=settings_snapshot,
233 )
235 return to_bool(value, default)
238def _get_optional_setting(
239 param_dict,
240 param_name,
241 setting_key,
242 settings_snapshot,
243 *,
244 cast=None,
245 check="not_none",
246):
247 """Fetch a setting and, when present, write it into ``param_dict``.
249 Consolidates the recurring
250 ``try: get_setting_from_snapshot(...) ... except NoSettingsContextError: pass``
251 pattern used by the LLM provider ``create_llm`` factories.
252 ``NoSettingsContextError`` is silently swallowed because these
253 parameters are always optional.
255 Args:
256 param_dict: Target dict (e.g. ``llm_params``) mutated in place.
257 param_name: Key to set on ``param_dict`` when a value is present.
258 setting_key: Dotted settings path to retrieve.
259 settings_snapshot: Snapshot dict passed through to
260 :func:`get_setting_from_snapshot`.
261 cast: Optional callable applied to the value before assignment
262 (e.g. ``int`` for ``max_tokens``).
263 check: ``"not_none"`` (default) writes the value when it is not
264 ``None``. ``"falsy"`` writes only when the value is truthy.
265 The falsy mode is an intentional safeguard for fields where
266 a falsy stored value (``organization=""``) must be dropped
267 rather than forwarded.
269 The ``max_tokens`` blocks that resolve their value through
270 ``compute_max_tokens`` / ``get_context_window_for_provider`` (rather
271 than a single settings lookup) do not fit this helper and are left
272 in place. The ``api_base`` block in ``openai.py`` is also left in
273 place because it wraps an SSRF validation side effect.
274 """
275 try:
276 value = get_setting_from_snapshot(
277 setting_key,
278 default=None,
279 settings_snapshot=settings_snapshot,
280 )
281 except NoSettingsContextError:
282 return
283 if check == "not_none":
284 if value is not None:
285 param_dict[param_name] = cast(value) if cast else value
286 elif check == "falsy": 286 ↛ 290line 286 didn't jump to line 290 because the condition on line 286 was always true
287 if value:
288 param_dict[param_name] = cast(value) if cast else value
289 else:
290 raise ValueError(f"Unknown check mode: {check!r}")