Coverage for src/local_deep_research/config/thread_settings.py: 100%
54 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +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
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 k, v in settings_snapshot.items():
109 if k.startswith(f"{key}."):
110 k = k.removeprefix(f"{key}.")
111 # Handle both full format {"value": x} and simplified format (just x)
112 if isinstance(v, dict) and "value" in v:
113 v = get_typed_setting_value(
114 k, v["value"], v.get("ui_element", "text")
115 )
116 # else: v is already the raw value from simplified snapshot
117 if value is _NOT_FOUND:
118 value = {k: v}
119 else:
120 value[k] = v
122 if value is not _NOT_FOUND:
123 # Extract value from dict structure if needed
124 return value
126 # Check if we have a settings context in this thread
127 if (
128 hasattr(_thread_local, "settings_context")
129 and _thread_local.settings_context
130 ):
131 value = _thread_local.settings_context.get_setting(key, default)
132 # Extract value from dict structure if needed (same as above)
133 if isinstance(value, dict) and "value" in value:
134 return value["value"]
135 return value
137 # If a default was provided, return it instead of raising an exception
138 if default is not None:
139 from loguru import logger
141 logger.debug(
142 f"Setting '{key}' not found in snapshot or context, using default"
143 )
144 return default
146 # Only raise the exception if no default was provided
147 raise NoSettingsContextError(
148 f"No settings context available in thread for key '{key}'. All settings must be passed via settings_snapshot."
149 )
152def get_bool_setting_from_snapshot(
153 key,
154 default=False,
155 username=None,
156 settings_snapshot=None,
157):
158 """Get a boolean setting from snapshot, handling string conversion.
160 This centralizes the string-to-boolean conversion logic for settings
161 retrieved from snapshots. Handles various truthy string representations
162 that may come from API requests, config files, or SQLite.
164 Args:
165 key: Setting key to retrieve
166 default: Default boolean value if setting not found
167 username: Username (unused, kept for backward compatibility)
168 settings_snapshot: Optional settings snapshot dict
170 Returns:
171 Boolean value of the setting
172 """
173 value = get_setting_from_snapshot(
174 key,
175 default,
176 username=username,
177 settings_snapshot=settings_snapshot,
178 )
180 return to_bool(value, default)