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

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 

31def set_settings_context(settings_context): 

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

33 _thread_local.settings_context = settings_context 

34 

35 

36def clear_settings_context(): 

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

38 

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 

44 

45 

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 

51 

52 

53@contextmanager 

54def settings_context(ctx): 

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

56 

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

58 when threads are reused in a pool. 

59 

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

69 

70 

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. 

78 

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 

84 

85 Returns: 

86 Setting value or default 

87 

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 

128 

129 if value is not _NOT_FOUND: 

130 # Extract value from dict structure if needed 

131 return value 

132 

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 

143 

144 # If a default was provided, return it instead of raising an exception 

145 if default is not None: 

146 from loguru import logger 

147 

148 logger.debug( 

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

150 ) 

151 return default 

152 

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 ) 

157 

158 

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. 

166 

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. 

170 

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 

176 

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 ) 

186 

187 return to_bool(value, default) 

188 

189 

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

200 

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. 

206 

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. 

220 

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