Coverage for src/local_deep_research/utilities/type_utils.py: 97%

96 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Type conversion utilities. 

3 

4This module provides type conversion functions that are used throughout 

5the codebase. It is intentionally kept free of internal dependencies to 

6avoid circular import issues. 

7""" 

8 

9from typing import Any, Dict, Optional 

10 

11 

12def to_positive_int(value: Any) -> int: 

13 """Parse a positive whole number without rounding or an upper clamp. 

14 

15 Accept integer values and decimal integer strings (including a .0 

16 suffix). Shared by summary-input setting reads and writes so a saved 

17 value cannot pass validation and then fail at fetch time. 

18 """ 

19 error = "Value must be a positive integer" 

20 if isinstance(value, str): 

21 whole, dot, fraction = value.strip().partition(".") 

22 if dot and fraction.strip("0"): 

23 raise ValueError(error) 

24 # Parse directly as an integer, avoiding float rounding and 

25 # expansion of compact exponent strings into enormous integers. 

26 value = whole 

27 elif not isinstance(value, (int, float)): 

28 raise ValueError(error) 

29 try: 

30 limit = int(value) 

31 except (TypeError, ValueError, OverflowError): 

32 # Raise below, outside the handler: ``from None`` only hides the 

33 # parser error, whose message holds the raw value, from tracebacks 

34 # while leaving it in ``__context__``. 

35 limit = None 

36 if ( 

37 limit is None 

38 or isinstance(value, bool) 

39 or (isinstance(value, float) and value != limit) 

40 or limit < 1 

41 ): 

42 raise ValueError(error) 

43 return limit 

44 

45 

46def to_bool(value: Any, default: bool = False) -> bool: 

47 """ 

48 Convert a value to boolean, handling string representations. 

49 

50 This is a standalone utility for converting any value to boolean, 

51 centralizing the string-to-boolean conversion logic that was 

52 previously scattered throughout the codebase. 

53 

54 Handles truthy string representations that may come from: 

55 - API requests 

56 - Configuration files 

57 - SQLite (which lacks native boolean type) 

58 - Environment variables 

59 

60 Args: 

61 value: The value to convert 

62 default: Default boolean if value is None 

63 

64 Returns: 

65 Boolean value 

66 

67 Examples: 

68 >>> to_bool("true") 

69 True 

70 >>> to_bool("yes") 

71 True 

72 >>> to_bool("1") 

73 True 

74 >>> to_bool("false") 

75 False 

76 >>> to_bool(1) 

77 True 

78 >>> to_bool(None, default=True) 

79 True 

80 """ 

81 if isinstance(value, bool): 

82 return value 

83 if isinstance(value, str): 

84 # Use strip() to handle whitespace that often appears in env vars 

85 # e.g., from shell parsing, config files, or copy-paste errors 

86 return value.strip().lower() in ("true", "1", "yes", "on", "enabled") 

87 if value is None: 

88 return default 

89 # For other types (int, etc.), use Python's bool conversion 

90 return bool(value) 

91 

92 

93def unwrap_setting(val: Any) -> Any: 

94 """Unwrap a setting that may be stored as ``{"value": x}`` or as ``x``. 

95 

96 Settings snapshots produced by ``SettingsManager.get_all_settings`` 

97 encode each value as a dict (``{"value": actual, "ui_element": ...}``), 

98 while simplified snapshots use the raw value. This helper normalizes 

99 a single value to its unwrapped form. 

100 

101 Returns ``val["value"]`` when ``val`` is a dict with a ``"value"`` key, 

102 otherwise ``val`` unchanged. ``None`` is preserved (use 

103 ``api.settings_utils.extract_setting_value`` if you want 

104 default-substitution semantics). 

105 

106 Lives in this dependency-free leaf module (rather than 

107 ``api.settings_utils``) so the many low-level callers — security/egress 

108 policy, the search-engine factory, notifications — can import it without 

109 dragging in the heavy ``api`` package ``__init__`` chain, which created 

110 an import cycle (``cannot import name 'PolicyDeniedError'``). 

111 """ 

112 if isinstance(val, dict) and "value" in val: 

113 return val["value"] 

114 return val 

115 

116 

117def resolve_boolean_setting( 

118 settings_snapshot: Optional[Dict[str, Any]], 

119 key: str, 

120 default: Optional[bool] = None, 

121) -> Optional[bool]: 

122 """Resolve a boolean setting from a settings snapshot. 

123 

124 Handles boolean values, dictionary settings envelopes (``{"value": ...}``), 

125 string encodings ("true"/"false"/"1"/"0"/"yes"/"no"/"on"/"off"), and 

126 returns ``default`` (None unless specified) when the setting is absent or unset. 

127 """ 

128 if not settings_snapshot or key not in settings_snapshot: 

129 return default 

130 raw = unwrap_setting(settings_snapshot[key]) 

131 if raw is None: 

132 return default 

133 if isinstance(raw, bool): 

134 return raw 

135 if isinstance(raw, str): 

136 val = raw.strip().lower() 

137 if val in ("false", "0", "off", "no"): 

138 return False 

139 if val in ("true", "1", "on", "yes"): 

140 return True 

141 return bool(raw) 

142 

143 

144def overlay_snapshot_value(snapshot: dict, key: str, value: Any) -> None: 

145 """Write an override into a settings snapshot without dropping envelope metadata. 

146 

147 Snapshots from ``SettingsManager.get_all_settings`` store 

148 ``{"value": actual, "ui_element": ...}``. Replacing the whole entry with a 

149 raw value breaks readers that call ``.get("value")``. Copy the envelope 

150 and replace only ``value`` when present; otherwise assign ``value`` raw. 

151 """ 

152 existing = snapshot.get(key) 

153 if isinstance(existing, dict) and "value" in existing: 

154 snapshot[key] = {**existing, "value": value} 

155 else: 

156 snapshot[key] = value 

157 

158 

159_RUNTIME_SETTING_MAP = ( 

160 ("search_engine", "search.tool"), 

161 ("search_tool", "search.tool"), 

162 ("model_provider", "llm.provider"), 

163 ("model", "llm.model"), 

164 ("custom_endpoint", "llm.openai_endpoint.url"), 

165 ("iterations", "search.iterations"), 

166 ("questions_per_iteration", "search.questions_per_iteration"), 

167 ("strategy", "search.search_strategy"), 

168 ("search_strategy", "search.search_strategy"), 

169 ("max_results", "search.max_results"), 

170 ("time_period", "search.time_period"), 

171) 

172 

173 

174def _is_strict_scope(snapshot: dict | None) -> bool: 

175 """Best-effort STRICT-scope detection on a settings snapshot. 

176 

177 Returns True when ``policy.egress_scope`` resolves to ``strict``, 

178 honoring the operator env override ``LDR_POLICY_EGRESS_SCOPE`` that 

179 ``context_from_snapshot`` itself honors. Used by 

180 :func:`overlay_runtime_settings` to leave ``search.tool`` untouched 

181 when STRICT is active — see the helper's docstring for the 

182 security rationale. 

183 

184 The env lookup goes through ``SettingsManager.check_env_setting`` 

185 (lazy import) to match the rest of the codebase's policy plumbing 

186 and to honor the project's env-var hook. When the snapshot is 

187 missing/corrupt and the env var is unset we return False so the 

188 overlay still works for callers that genuinely need it (e.g. tests 

189 with synthetic snapshots). 

190 """ 

191 try: 

192 from ..settings.manager import check_env_setting 

193 except ImportError: 

194 return False 

195 

196 env_scope = check_env_setting("policy.egress_scope") 

197 if env_scope: 

198 return str(env_scope).strip().lower() == "strict" 

199 if not isinstance(snapshot, dict): 

200 return False 

201 raw = snapshot.get("policy.egress_scope") 

202 if isinstance(raw, dict) and "value" in raw: 

203 raw = raw["value"] 

204 if raw is None: 

205 return False 

206 return str(raw).strip().lower() == "strict" 

207 

208 

209def overlay_runtime_settings( 

210 snapshot: dict | None, **runtime: Any 

211) -> dict | None: 

212 """Overlay per-run kwargs onto a settings snapshot in place. 

213 

214 ``None`` values are skipped so callers can pass through unspecified 

215 fields without clobbering snapshot defaults. 

216 

217 STRICT-scope security note: under ``policy.egress_scope == strict`` 

218 the factory PEP only admits the saved primary engine 

219 (``search.tool``), so overwriting ``search.tool`` with the requested 

220 ``search_engine``/``search_tool`` kwarg would silently widen the 

221 run to that engine — every other entrypoint (start-research 

222 precheck, REST ``/api/v1/quick_summary``, programmatic 

223 ``quick_summary`` / ``generate_report`` / ``detailed_research``, 

224 the background worker) feeds its kwargs through this helper, so the 

225 skip MUST live here, not at any individual call site. The precheck 

226 still evaluates against the saved primary; this skip just keeps 

227 that contract true for every other entrypoint too. 

228 """ 

229 if not isinstance(snapshot, dict): 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 return snapshot 

231 # Resolve STRICT once; only the two kwargs that write search.tool 

232 # are affected (search_engine + the legacy search_tool alias). 

233 strict_scope = _is_strict_scope(snapshot) 

234 for arg, key in _RUNTIME_SETTING_MAP: 

235 if arg in runtime and runtime[arg] is not None: 

236 if ( 

237 strict_scope 

238 and key == "search.tool" 

239 and arg in ("search_engine", "search_tool") 

240 ): 

241 continue 

242 overlay_snapshot_value(snapshot, key, runtime[arg]) 

243 return snapshot 

244 

245 

246def resolve_snippets_only( 

247 settings_snapshot: Optional[Dict[str, Any]], 

248) -> Optional[bool]: 

249 """Resolve boolean ``search.snippets_only`` from settings snapshot. 

250 

251 Handles boolean values, dictionary settings envelopes (``{"value": ...}``), 

252 string encodings ("true"/"false"/"1"/"0"/"yes"/"no"/"on"/"off"), and 

253 returns None when the setting is absent or unset. 

254 

255 Fails closed: unrecognized or corrupt values resolve to True (snippets-only) 

256 so that network-fetching full content is never inadvertently enabled. 

257 """ 

258 if not settings_snapshot or "search.snippets_only" not in settings_snapshot: 

259 return None 

260 raw = settings_snapshot["search.snippets_only"] 

261 if isinstance(raw, dict): 

262 raw = raw.get("value") 

263 if raw is None: 

264 return None 

265 if isinstance(raw, bool): 

266 return raw 

267 if isinstance(raw, str): 

268 val = raw.strip().lower() 

269 if val in ("false", "0", "off", "no"): 

270 return False 

271 if val in ("true", "1", "on", "yes"): 

272 return True 

273 return True 

274 if isinstance(raw, (int, float)): 

275 return bool(raw) 

276 return True