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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Type conversion utilities.
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"""
9from typing import Any, Dict, Optional
12def to_positive_int(value: Any) -> int:
13 """Parse a positive whole number without rounding or an upper clamp.
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
46def to_bool(value: Any, default: bool = False) -> bool:
47 """
48 Convert a value to boolean, handling string representations.
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.
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
60 Args:
61 value: The value to convert
62 default: Default boolean if value is None
64 Returns:
65 Boolean value
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)
93def unwrap_setting(val: Any) -> Any:
94 """Unwrap a setting that may be stored as ``{"value": x}`` or as ``x``.
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.
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).
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
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.
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)
144def overlay_snapshot_value(snapshot: dict, key: str, value: Any) -> None:
145 """Write an override into a settings snapshot without dropping envelope metadata.
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
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)
174def _is_strict_scope(snapshot: dict | None) -> bool:
175 """Best-effort STRICT-scope detection on a settings snapshot.
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.
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
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"
209def overlay_runtime_settings(
210 snapshot: dict | None, **runtime: Any
211) -> dict | None:
212 """Overlay per-run kwargs onto a settings snapshot in place.
214 ``None`` values are skipped so callers can pass through unspecified
215 fields without clobbering snapshot defaults.
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
246def resolve_snippets_only(
247 settings_snapshot: Optional[Dict[str, Any]],
248) -> Optional[bool]:
249 """Resolve boolean ``search.snippets_only`` from settings snapshot.
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.
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