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

1"""Shared thread-local storage for settings context 

2 

3This module provides a single thread-local storage instance that can be 

4shared across different modules to maintain settings context in threads. 

5""" 

6 

7import threading 

8from contextlib import contextmanager 

9 

10from ..settings.manager import get_typed_setting_value, is_valid_setting_key 

11from ..utilities.type_utils import to_bool 

12 

13 

14class NoSettingsContextError(Exception): 

15 """Raised when settings context is not available in a thread.""" 

16 

17 pass 

18 

19 

20# Shared thread-local storage for settings context 

21_thread_local = threading.local() 

22 

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() 

29 

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() 

37 

38 

39def set_settings_context(settings_context): 

40 """Set a settings context for the current thread.""" 

41 _thread_local.settings_context = settings_context 

42 

43 

44def clear_settings_context(): 

45 """Clear the settings context for the current thread. 

46 

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 

52 

53 

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 

59 

60 

61@contextmanager 

62def settings_context(ctx): 

63 """Context manager that sets and clears settings context automatically. 

64 

65 Ensures cleanup even if an exception occurs, preventing context leaks 

66 when threads are reused in a pool. 

67 

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() 

77 

78 

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. 

86 

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 

97 

98 Returns: 

99 Setting value or default 

100 

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 

143 

144 if value is not _NOT_FOUND: 

145 # Extract value from dict structure if needed 

146 return value 

147 

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 

166 

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 

174 

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 

189 

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 

195 

196 logger.debug( 

197 f"Setting '{key}' not found in snapshot or context, using default" 

198 ) 

199 return default 

200 

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 ) 

205 

206 

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. 

214 

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. 

218 

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 

224 

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 ) 

234 

235 return to_bool(value, default) 

236 

237 

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``. 

248 

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. 

254 

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. 

268 

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}")