Coverage for src/local_deep_research/web_search_engines/search_engine_base.py: 95%

399 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1import json 

2import time 

3from abc import ABC, abstractmethod 

4from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union 

5from urllib.parse import urlparse 

6 

7from langchain_core.language_models import BaseLLM 

8from ..security.secure_logging import logger 

9from tenacity import ( 

10 RetryError, 

11 retry, 

12 retry_if_exception_type, 

13 stop_after_attempt, 

14) 

15from tenacity.wait import wait_base 

16 

17from ..advanced_search_system.filters.base_filter import BaseFilter 

18from ..utilities.type_utils import unwrap_setting 

19from ..config.constants import DEFAULT_MAX_FILTERED_RESULTS 

20from ..config.thread_settings import get_setting_from_snapshot 

21from ..security.log_sanitizer import sanitize_error_message, scrub_error 

22from ..security.egress.classification import Exposure, Sensitivity 

23from ..utilities.thread_context import clear_search_context, set_search_context 

24 

25from .rate_limiting import RateLimitError, get_tracker 

26from ..constants import DEFAULT_SEARCH_TOOL 

27 

28if TYPE_CHECKING: 

29 from ..advanced_search_system.filters.journal_reputation_filter import ( 

30 JournalReputationFilter, 

31 ) 

32 

33 

34# Common placeholder values that should never be treated as a real API key. 

35# Three call sites previously had inconsistent subsets of this list, which 

36# let invalid placeholders silently through the production path in 

37# search_engines_config.py. Centralizing here so all three stay in sync. 

38API_KEY_PLACEHOLDERS = frozenset( 

39 { 

40 "", 

41 "None", 

42 "null", 

43 "PLACEHOLDER", 

44 "YOUR_API_KEY_HERE", 

45 "YOUR_API_KEY", 

46 "API_KEY", 

47 "your_api_key", 

48 "your-api-key", 

49 } 

50) 

51 

52 

53def _is_api_key_placeholder(api_key: Optional[str]) -> bool: 

54 """Return True if ``api_key`` looks like a placeholder, not a real key. 

55 

56 Catches: 

57 - Exact-match placeholders (see ``API_KEY_PLACEHOLDERS``) 

58 - Environment-variable-style names ending in ``_API_KEY`` 

59 (e.g. ``BRAVE_API_KEY``) 

60 - Templates starting with ``YOUR_`` 

61 - Angle-bracket templates: ``<key>`` or ``${KEY}`` 

62 """ 

63 if not api_key: 

64 return True 

65 api_key = api_key.strip() 

66 if api_key in API_KEY_PLACEHOLDERS: 

67 return True 

68 if api_key.endswith("_API_KEY"): 

69 return True 

70 if api_key.startswith("YOUR_"): 

71 return True 

72 if api_key.startswith("<") and api_key.endswith(">"): 

73 return True 

74 if api_key.startswith("${") and api_key.endswith("}"): 

75 return True 

76 return False 

77 

78 

79class AdaptiveWait(wait_base): 

80 """Custom wait strategy that uses adaptive rate limiting.""" 

81 

82 def __init__(self, get_wait_func): 

83 self.get_wait_func = get_wait_func 

84 

85 def __call__(self, retry_state): 

86 return self.get_wait_func() 

87 

88 

89class BaseSearchEngine(ABC): 

90 """ 

91 Abstract base class for search engines with two-phase retrieval capability. 

92 Handles common parameters and implements the two-phase search approach. 

93 

94 Subclass contract for ``__init__`` 

95 --------------------------------- 

96 Concrete engines should forward ``settings_snapshot`` and 

97 ``programmatic_mode`` to ``super().__init__`` so the base class can wire 

98 them up correctly. The cleanest way is to declare ``**kwargs`` and pass 

99 it along:: 

100 

101 def __init__(self, max_results=10, *, my_param=None, **kwargs): 

102 super().__init__(max_results=max_results, **kwargs) 

103 self.my_param = my_param 

104 

105 Many existing engines accept ``**kwargs`` but don't forward — that 

106 silently drops ``programmatic_mode`` (and used to bind the wrong rate 

107 tracker). The factory has a post-construction safety net that calls 

108 ``_configure_programmatic_mode`` when the engine's mode doesn't match 

109 what the caller asked for, but new engines should not rely on it: the 

110 safety net only covers ``programmatic_mode``, not ``settings_snapshot`` 

111 or other base kwargs. 

112 """ 

113 

114 # Class attribute to indicate if this engine searches public internet sources 

115 # Should be overridden by subclasses - defaults to False for safety 

116 is_public = False 

117 

118 # Class attribute to indicate if this is a generic search engine (vs specialized) 

119 # Generic engines are general web search (Google, Bing, etc) vs specialized (arXiv, PubMed). 

120 # Note: generic does NOT imply good native ranking — see is_lexical. 

121 is_generic = False 

122 

123 # Class attribute to indicate if this is a scientific/academic search engine 

124 # Scientific engines include arXiv, PubMed, Semantic Scholar, etc. 

125 is_scientific = False 

126 

127 # Class attribute to indicate if this is a local RAG/document search engine 

128 # Local engines search private document collections stored locally 

129 is_local = False 

130 

131 # Egress data classification (ADR-0007). Two orthogonal axes, declared 

132 # explicitly per engine alongside is_public/is_local. The defaults fail 

133 # closed: an engine that forgets to classify itself is treated as the most 

134 # restrictive quadrant (sensitive data + exposing sink → usable only by 

135 # itself). Engines whose label depends on configuration (a collection's 

136 # is_public flag, a self-hosted store's URL) are refined by the classifier 

137 # at run time; these constants are the static fallback. 

138 egress_sensitivity = Sensitivity.SENSITIVE 

139 egress_exposure = Exposure.EXPOSING 

140 

141 # Class attribute to indicate if this is a news search engine 

142 # News engines specialize in news articles and current events 

143 is_news = False 

144 

145 # Class attribute to indicate if this is a code search engine 

146 # Code engines specialize in searching code repositories 

147 is_code = False 

148 

149 # Class attribute to indicate if this is a book/literature search engine 

150 # Book engines search libraries and literary archives 

151 is_books = False 

152 

153 # Classification: does this engine use lexical/keyword-based search? 

154 # Lexical engines (arXiv, PubMed, Wikipedia, Mojeek, etc.) match results by 

155 # keywords without ML-based ranking. This is an informational flag that can 

156 # drive multiple behaviors (query optimization, result deduplication, UI hints). 

157 # For LLM relevance filtering specifically, see needs_llm_relevance_filter. 

158 is_lexical = False 

159 

160 # Behavioral: should the factory auto-enable LLM relevance filtering? 

161 # When True, the factory sets enable_llm_relevance_filter=True on the engine 

162 # instance, causing _filter_for_relevance() to run after previews are fetched. 

163 # Typically set alongside is_lexical=True, but can be set independently — 

164 # e.g. a non-lexical engine with noisy results could opt in. 

165 needs_llm_relevance_filter = False 

166 

167 # Tuning for the LLM relevance filter (only applies when the filter 

168 # is active for this engine). 

169 # 

170 # relevance_filter_batch_size: split previews into chunks of this many 

171 # before sending to the LLM. Smaller batches are faster per call and 

172 # more reliable on weaker models which struggle with many indices in 

173 # one context. None or 0 = single-call mode (no batching). 

174 # 

175 # relevance_filter_max_parallel_batches: number of batches to dispatch 

176 # concurrently against the LLM. 1 = sequential. Most providers handle 

177 # parallel requests fine (Ollama with OLLAMA_NUM_PARALLEL>1, OpenAI, 

178 # Anthropic). 

179 relevance_filter_batch_size: Optional[int] = 5 

180 relevance_filter_max_parallel_batches: int = 10 

181 

182 # Class attribute for rate limit detection patterns 

183 # Subclasses can override to add engine-specific patterns 

184 rate_limit_patterns: Set[str] = { 

185 "rate limit", 

186 "rate_limit", 

187 "ratelimit", 

188 "too many requests", 

189 "throttl", 

190 "quota exceeded", 

191 "quota_exceeded", 

192 "limit exceeded", 

193 "request limit", 

194 "api limit", 

195 "usage limit", 

196 } 

197 

198 # Instance-attribute names holding credential values to redact from error 

199 # messages/logs via _scrub_error(). Subclasses override when they store 

200 # secrets under different names (e.g. Elasticsearch: _api_key/_password). 

201 # Centralizing the list keeps every dual-scrub call site uniform and 

202 # prevents the per-site drift that previously dropped a secret. 

203 _secret_attrs: tuple[str, ...] = ("api_key",) 

204 

205 @staticmethod 

206 def _ensure_list(value, *, default=None): 

207 """Normalize a value that should be a list. 

208 

209 Handles JSON-encoded strings, comma-separated strings, and 

210 already-parsed lists. Returns *default* (empty list when not 

211 supplied) for ``None`` or empty/unparseable input. 

212 """ 

213 if default is None: 

214 default = [] 

215 if value is None: 

216 return default 

217 if isinstance(value, list): 

218 return value 

219 if isinstance(value, str): 

220 stripped = value.strip() 

221 if not stripped: 

222 return default 

223 if stripped.startswith("["): 

224 try: 

225 parsed = json.loads(stripped) 

226 if isinstance(parsed, list): 226 ↛ 230line 226 didn't jump to line 230 because the condition on line 226 was always true

227 return [str(item) for item in parsed] 

228 except (json.JSONDecodeError, ValueError, RecursionError): 

229 pass 

230 return [ 

231 item.strip() for item in stripped.split(",") if item.strip() 

232 ] 

233 return default 

234 

235 @classmethod 

236 def _load_engine_class(cls, name: str, config: Dict[str, Any]): 

237 """ 

238 Helper method to load an engine class dynamically. 

239 

240 Args: 

241 name: Engine name 

242 config: Engine configuration dict with module_path and class_name 

243 

244 Returns: 

245 Tuple of (success: bool, engine_class or None, error_msg or None) 

246 """ 

247 from ..security.module_whitelist import ( 

248 ModuleNotAllowedError, 

249 get_safe_module_class, 

250 ) 

251 

252 try: 

253 module_path = config.get("module_path") 

254 class_name = config.get("class_name") 

255 

256 if not module_path or not class_name: 

257 return ( 

258 False, 

259 None, 

260 f"Missing module_path or class_name for {name}", 

261 ) 

262 

263 # Use whitelist-validated safe import 

264 engine_class = get_safe_module_class(module_path, class_name) 

265 

266 return True, engine_class, None 

267 

268 except ModuleNotAllowedError as e: 

269 return ( 

270 False, 

271 None, 

272 f"Security error loading engine class for {name}: {e}", 

273 ) 

274 except Exception as e: 

275 return False, None, f"Could not load engine class for {name}: {e}" 

276 

277 def __init__( 

278 self, 

279 llm: Optional[BaseLLM] = None, 

280 max_filtered_results: Optional[int] = None, 

281 max_results: Optional[int] = 10, # Default value if not provided 

282 preview_filters: List[BaseFilter] | None = None, 

283 content_filters: List[BaseFilter] | None = None, 

284 search_snippets_only: bool = True, # New parameter with default 

285 include_full_content: bool = False, 

286 settings_snapshot: Optional[Dict[str, Any]] = None, 

287 programmatic_mode: bool = False, 

288 **kwargs, 

289 ): 

290 """ 

291 Initialize the search engine with common parameters. 

292 

293 Args: 

294 llm: Optional language model for relevance filtering 

295 max_filtered_results: Maximum number of results to keep after filtering 

296 max_results: Maximum number of search results to return 

297 preview_filters: Filters that will be applied to all previews 

298 produced by the search engine, before relevancy checks. 

299 content_filters: Filters that will be applied to the full content 

300 produced by the search engine, after relevancy checks. 

301 search_snippets_only: Whether to return only snippets or full content 

302 include_full_content: Whether to use FullSearchResults for full webpage content 

303 settings_snapshot: Settings snapshot for configuration 

304 programmatic_mode: If True, disables database operations and uses memory-only tracking 

305 **kwargs: Additional engine-specific parameters 

306 """ 

307 if max_filtered_results is None: 

308 max_filtered_results = DEFAULT_MAX_FILTERED_RESULTS 

309 if max_results is None: 

310 max_results = 10 

311 self._preview_filters: List[BaseFilter] = ( 

312 preview_filters if preview_filters is not None else [] 

313 ) 

314 self._content_filters: List[BaseFilter] = ( 

315 content_filters if content_filters is not None else [] 

316 ) 

317 

318 self.llm = llm # LLM for relevance filtering 

319 self._max_filtered_results = int( 

320 max_filtered_results 

321 ) # Ensure it's an integer 

322 self._max_results = max( 

323 1, int(max_results) 

324 ) # Ensure it's a positive integer 

325 self.search_snippets_only = search_snippets_only # Store the setting 

326 self.include_full_content = include_full_content 

327 self.settings_snapshot = ( 

328 settings_snapshot or {} 

329 ) # Store settings snapshot 

330 

331 self.engine_type = self.__class__.__name__ 

332 self._engine_name: str = "" # set by the factory after construction 

333 # The snapshot dict the runtime egress backstop last verified 

334 # against (identity comparison; holding the reference keeps the 

335 # memo valid — a verification re-runs when the caller assigns a 

336 # NEW snapshot) plus the policy-relevant VALUES it carried at 

337 # verification time, so an in-place mutation of the scope or 

338 # primary key on the same dict is also caught. 

339 self._egress_verified_snapshot: Optional[Dict[str, Any]] = None 

340 self._egress_verified_policy_key: Optional[tuple] = None 

341 self._egress_skip_warned = False 

342 self._configure_programmatic_mode(programmatic_mode) 

343 self._last_wait_time = ( 

344 0.0 # Default to 0 for successful searches without rate limiting 

345 ) 

346 self._last_results_count = 0 

347 

348 def _create_journal_filter( 

349 self, 

350 engine_name: str, 

351 llm: Optional[BaseLLM], 

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

353 ) -> Optional["JournalReputationFilter"]: 

354 """Build the default :class:`JournalReputationFilter` for this engine. 

355 

356 Wraps the identical 8-line ``JournalReputationFilter.create_default`` 

357 boilerplate that previously lived in every academic subclass. The 

358 ``engine_name`` is passed explicitly because the auto-derived class 

359 name does not match the settings key for ``nasa_ads`` (would be 

360 ``"nasaads"``) or ``semantic_scholar`` (would be 

361 ``"semanticscholar"``). 

362 

363 ``llm`` and ``settings_snapshot`` are passed in rather than read from 

364 ``self`` because subclasses build their preview filters *before* 

365 calling ``super().__init__()`` (the filter is handed to the parent 

366 constructor), so ``self.llm`` / ``self.settings_snapshot`` do not 

367 exist yet at call time. 

368 

369 Args: 

370 engine_name: Settings key identifying the engine (e.g. 

371 ``"arxiv"``, ``"nasa_ads"``). 

372 llm: Language model used for the filter's relevance pass. 

373 settings_snapshot: Settings snapshot for configuration lookups. 

374 

375 Returns: 

376 A configured :class:`JournalReputationFilter`, or ``None`` if 

377 filtering is disabled in settings for this engine. 

378 """ 

379 from ..advanced_search_system.filters.journal_reputation_filter import ( 

380 JournalReputationFilter, 

381 ) 

382 

383 return JournalReputationFilter.create_default( 

384 model=llm, # type: ignore[arg-type] 

385 engine_name=engine_name, 

386 settings_snapshot=settings_snapshot, 

387 ) 

388 

389 def _configure_programmatic_mode(self, programmatic_mode: bool) -> None: 

390 """Set ``programmatic_mode`` and (re)bind the matching rate tracker. 

391 

392 Called from ``__init__`` and from the factory as a fallback when an 

393 engine subclass swallows the ``programmatic_mode`` kwarg without 

394 forwarding it to ``super().__init__``. Safe to call after init — 

395 rebinding ``rate_tracker`` discards the previous tracker (no 

396 resources to release) and the new one starts with empty in-memory 

397 caches. 

398 """ 

399 self.programmatic_mode = programmatic_mode 

400 if programmatic_mode: 

401 from .rate_limiting.tracker import AdaptiveRateLimitTracker 

402 

403 self.rate_tracker = AdaptiveRateLimitTracker( 

404 settings_snapshot=self.settings_snapshot, 

405 programmatic_mode=programmatic_mode, 

406 ) 

407 else: 

408 self.rate_tracker = get_tracker() 

409 

410 @property 

411 def max_filtered_results(self) -> int: 

412 """Get the maximum number of filtered results.""" 

413 return self._max_filtered_results 

414 

415 @max_filtered_results.setter 

416 def max_filtered_results(self, value: int) -> None: 

417 """Set the maximum number of filtered results.""" 

418 if value is None: 

419 value = DEFAULT_MAX_FILTERED_RESULTS 

420 logger.warning( 

421 f"Setting max_filtered_results to {DEFAULT_MAX_FILTERED_RESULTS}" 

422 ) 

423 self._max_filtered_results = int(value) 

424 

425 @property 

426 def max_results(self) -> int: 

427 """Get the maximum number of search results.""" 

428 return self._max_results 

429 

430 @max_results.setter 

431 def max_results(self, value: int) -> None: 

432 """Set the maximum number of search results.""" 

433 if value is None: 

434 value = 10 

435 self._max_results = max(1, int(value)) 

436 

437 def _get_adaptive_wait(self) -> float: 

438 """Get adaptive wait time from tracker.""" 

439 wait_time = self.rate_tracker.get_wait_time(self.engine_type) 

440 self._last_wait_time = wait_time 

441 logger.debug( 

442 f"{self.engine_type} waiting {wait_time:.2f}s before retry" 

443 ) 

444 return wait_time 

445 

446 def _record_retry_outcome(self, retry_state) -> None: 

447 """Record outcome after retry completes.""" 

448 success = ( 

449 not retry_state.outcome.failed if retry_state.outcome else False 

450 ) 

451 self.rate_tracker.record_outcome( 

452 self.engine_type, 

453 self._last_wait_time or 0, 

454 success, 

455 retry_state.attempt_number, 

456 error_type="RateLimitError" if not success else None, 

457 search_result_count=self._last_results_count if success else 0, 

458 ) 

459 

460 def _verify_egress_scope(self) -> None: 

461 """Runtime backstop: verify this engine is allowed under the egress 

462 scope in ``self.settings_snapshot`` before executing a search. 

463 

464 No-op when ``settings_snapshot`` is missing or empty (programmatic 

465 API callers) or when ``_engine_name`` has not been set. Raises 

466 ``PolicyDeniedError`` when the policy denies the engine; any other 

467 internal error in the policy evaluation is logged and ignored so a 

468 broken backstop never takes down searches the factory PEP already 

469 approved. 

470 

471 This is a defense-in-depth check behind the factory PEP, the 

472 strategy-level filters, and the audit hook. It covers engines that 

473 bypassed the factory (direct instantiation with a snapshot). Note 

474 the limit: the snapshot is the one captured at construction, so a 

475 scope change AFTER construction is only caught if the caller also 

476 refreshes ``settings_snapshot`` (assigns a NEW dict — in-place 

477 mutation of the existing dict is not detected, see below). 

478 

479 The verification is memoized per snapshot IDENTITY plus the 

480 policy-relevant VALUES (scope, primary): the full policy inputs 

481 are stable for a given snapshot object, so re-evaluating on every 

482 ``run()`` would only repeat the same decision — and under 

483 ADAPTIVE scope with a URL-configurable primary the evaluation can 

484 include a DNS lookup (bounded at 2s), which must not become a 

485 per-search stall. Assigning a refreshed snapshot OR mutating the 

486 scope/primary keys in place invalidates the memo and re-verifies. 

487 Denials are never memoized (they raise). 

488 """ 

489 if not self.settings_snapshot: 

490 return 

491 if not self._engine_name: 

492 # A snapshot WITHOUT a stamped engine name means a direct 

493 # instantiation the factory never saw and no subclass 

494 # self-stamps — the backstop cannot evaluate it. Surface the 

495 # gap in the audit log (once per instance) instead of 

496 # skipping silently. 

497 if not self._egress_skip_warned: 497 ↛ 504line 497 didn't jump to line 504 because the condition on line 497 was always true

498 self._egress_skip_warned = True 

499 logger.bind(policy_audit=True).warning( 

500 "egress backstop skipped: engine has a settings " 

501 "snapshot but no _engine_name stamped", 

502 engine_type=self.__class__.__name__, 

503 ) 

504 return 

505 if ( 

506 self.settings_snapshot is self._egress_verified_snapshot 

507 and self._egress_policy_key() == self._egress_verified_policy_key 

508 ): 

509 return 

510 from ..security.egress.policy import PolicyDeniedError 

511 

512 try: 

513 self._check_egress_policy() 

514 except PolicyDeniedError: 

515 logger.bind(policy_audit=True).warning( 

516 "runtime egress backstop denied engine", 

517 engine=self._engine_name, 

518 ) 

519 raise 

520 except Exception: 

521 logger.bind(policy_audit=True).debug( 

522 "egress runtime verify errored; " 

523 "factory PEP still enforces at instantiation", 

524 engine=self._engine_name, 

525 ) 

526 else: 

527 self._egress_verified_snapshot = self.settings_snapshot 

528 self._egress_verified_policy_key = self._egress_policy_key() 

529 

530 def _egress_policy_key(self) -> tuple: 

531 """The policy-relevant snapshot values the backstop memo guards on. 

532 

533 Cheap dict reads only — no context construction, no DNS. Not an 

534 exhaustive input set (URL settings or DB-side collection flags can 

535 also influence the decision), but it covers the keys an in-place 

536 mutation would realistically target; the factory PEP remains the 

537 primary enforcement for everything else. 

538 

539 MAINTENANCE: if a new snapshot key ever becomes policy-relevant in 

540 ``context_from_snapshot``/``evaluate_engine``, add it here too — 

541 otherwise an in-place mutation of that key returns a stale memo. 

542 

543 """ 

544 scope = unwrap_setting( 

545 self.settings_snapshot.get("policy.egress_scope") 

546 ) 

547 primary = unwrap_setting(self.settings_snapshot.get("search.tool")) 

548 return (scope, primary) 

549 

550 def _check_egress_policy(self) -> None: 

551 """Inner helper so the raise is not inside the except handler 

552 (ruff TRY301). Raises PolicyDeniedError on scope mismatch.""" 

553 from ..security.egress.policy import ( 

554 PolicyDeniedError, 

555 context_from_snapshot, 

556 evaluate_engine, 

557 ) 

558 

559 primary = unwrap_setting( 

560 self.settings_snapshot.get("search.tool", self._engine_name) 

561 ) 

562 ctx = context_from_snapshot( 

563 self.settings_snapshot, 

564 primary or self._engine_name, 

565 username=self.settings_snapshot.get("_username"), 

566 ) 

567 decision = evaluate_engine( 

568 self._engine_name, 

569 ctx, 

570 settings_snapshot=self.settings_snapshot, 

571 ) 

572 if not decision.allowed: 

573 raise PolicyDeniedError(decision, target=self._engine_name) 

574 

575 def run( 

576 self, query: str, research_context: Dict[str, Any] | None = None 

577 ) -> List[Dict[str, Any]]: 

578 """ 

579 Run the search engine with a given query, retrieving and filtering results. 

580 This implements a two-phase retrieval approach: 

581 1. Get preview information for many results 

582 2. Filter the previews for relevance 

583 3. Get full content for only the relevant results 

584 

585 Args: 

586 query: The search query 

587 research_context: Context from previous research to use. 

588 

589 Returns: 

590 List of search results with full content (if available) 

591 """ 

592 logger.info(f"---Execute a search using {self.__class__.__name__}---") 

593 

594 # Runtime egress scope backstop: verify this engine is allowed 

595 # before making any HTTP requests. 

596 self._verify_egress_scope() 

597 

598 # Track search call for metrics (if available and not in programmatic mode) 

599 should_record_metrics = False 

600 context_was_set = False 

601 if not self.programmatic_mode: 

602 from ..metrics.search_tracker import SearchTracker 

603 

604 should_record_metrics = True 

605 

606 # For thread-safe context propagation: if we have research_context parameter, use it 

607 # Otherwise, try to inherit from current thread context (normal case) 

608 # This allows strategies running in threads to explicitly pass context when needed 

609 if research_context: 

610 # Explicit context provided - use it and set it for this thread 

611 set_search_context(research_context) 

612 context_was_set = True 

613 

614 engine_name = self.__class__.__name__.replace( 

615 "SearchEngine", "" 

616 ).lower() 

617 start_time = time.time() 

618 

619 success = True 

620 error_message = None 

621 results_count = 0 

622 

623 # Define the core search function with retry logic 

624 if self.rate_tracker.enabled: 

625 # Rate limiting enabled - use retry with adaptive wait 

626 @retry( 

627 stop=stop_after_attempt(3), 

628 wait=AdaptiveWait(lambda: self._get_adaptive_wait()), 

629 retry=retry_if_exception_type((RateLimitError,)), 

630 after=self._record_retry_outcome, 

631 reraise=True, 

632 ) 

633 def _run_with_retry(): 

634 nonlocal success, error_message, results_count 

635 return _execute_search() 

636 else: 

637 # Rate limiting disabled - run without retry 

638 def _run_with_retry(): 

639 nonlocal success, error_message, results_count 

640 return _execute_search() 

641 

642 def _execute_search(): 

643 nonlocal success, error_message, results_count 

644 

645 try: 

646 # Step 1: Get preview information for items 

647 previews = self._get_previews(query) 

648 if not previews: 

649 logger.info( 

650 f"Search engine {self.__class__.__name__} returned no preview results for query: {query}" 

651 ) 

652 results_count = 0 

653 return [] 

654 

655 # Pre-filter enrichment: resolve DOIs to OpenAlex source 

656 # IDs BEFORE the preview filters run. The 

657 # JournalReputationFilter is registered as a preview 

658 # filter and uses ``result["openalex_source_id"]`` for 

659 # Tier 2 lookups; running enrichment afterwards (as the 

660 # old pipeline did) left the field empty at filter time, 

661 # silently forcing a fragile-name-match fallback. Only 

662 # for scientific engines whose results carry DOIs. 

663 if getattr(self, "is_scientific", False): 

664 try: 

665 from ..utilities.openalex_enrichment import ( 

666 enrich_results_with_source_ids, 

667 ) 

668 

669 email = getattr(self, "email", None) 

670 previews = enrich_results_with_source_ids( 

671 previews, email=email 

672 ) 

673 except Exception: 

674 logger.debug( 

675 "DOI enrichment skipped (import or call failed)" 

676 ) 

677 

678 for preview_filter in self._preview_filters: 

679 previews = preview_filter.filter_results(previews, query) 

680 

681 # Step 2: Filter previews for relevance with LLM 

682 enable_llm_filter = getattr( 

683 self, "enable_llm_relevance_filter", False 

684 ) 

685 

686 if enable_llm_filter and self.llm: 

687 filtered_items = self._filter_for_relevance(previews, query) 

688 else: 

689 filtered_items = previews 

690 logger.debug( 

691 f"[{type(self).__name__}] Relevance filter skipped " 

692 f"(enabled={enable_llm_filter}, " 

693 f"llm={'yes' if self.llm else 'no'})" 

694 ) 

695 

696 # Step 3: Get full content for filtered items 

697 if self.search_snippets_only: 

698 logger.info("Returning snippet-only results as per config") 

699 results = filtered_items 

700 else: 

701 results = self._get_full_content(filtered_items) 

702 

703 for content_filter in self._content_filters: 

704 results = content_filter.filter_results(results, query) 

705 

706 results_count = len(results) 

707 self._last_results_count = results_count 

708 

709 # Record success if we get here and rate limiting is enabled 

710 if self.rate_tracker.enabled: 

711 logger.info( 

712 f"Recording successful search for {self.engine_type}: wait_time={self._last_wait_time}s, results={results_count}" 

713 ) 

714 self.rate_tracker.record_outcome( 

715 self.engine_type, 

716 self._last_wait_time, 

717 success=True, 

718 retry_count=1, # First attempt succeeded 

719 search_result_count=results_count, 

720 ) 

721 else: 

722 logger.info( 

723 f"Rate limiting disabled, not recording search for {self.engine_type}" 

724 ) 

725 

726 return results 

727 

728 except RateLimitError: 

729 # Only re-raise if rate limiting is enabled 

730 if self.rate_tracker.enabled: 

731 raise 

732 # If rate limiting is disabled, treat as regular error 

733 success = False 

734 error_message = "Rate limit hit but rate limiting disabled" 

735 logger.warning( 

736 f"Rate limit hit on {self.__class__.__name__} but rate limiting is disabled" 

737 ) 

738 results_count = 0 

739 return [] 

740 except Exception as e: 

741 # Other errors - don't retry 

742 success = False 

743 # Sanitize before it flows to SearchTracker.record_search 

744 # (and the database) and to the log. Use logger.warning with 

745 # the dual-scrubbed text instead of logger.exception: the 

746 # cause chain frequently carries the request URL or auth 

747 # header from upstream HTTP clients (see #4131). 

748 error_message = safe_msg = self._scrub_error(e) 

749 logger.warning( 

750 f"Search engine {self.__class__.__name__} failed: {safe_msg}" 

751 ) 

752 results_count = 0 

753 return [] 

754 

755 try: 

756 return _run_with_retry() # type: ignore[no-any-return] 

757 except RetryError as e: 

758 # All retries exhausted 

759 success = False 

760 error_message = self._scrub_error( 

761 f"Rate limited after all retries: {e}" 

762 ) 

763 safe_msg = self._scrub_error(e) 

764 logger.warning( 

765 f"{self.__class__.__name__} failed after all retries: {safe_msg}" 

766 ) 

767 return [] 

768 except Exception as e: 

769 success = False 

770 error_message = safe_msg = self._scrub_error(e) 

771 logger.warning( 

772 f"Search engine {self.__class__.__name__} error: {safe_msg}" 

773 ) 

774 return [] 

775 finally: 

776 try: 

777 # Record search metrics BEFORE clearing context (record_search needs it) 

778 if should_record_metrics: 

779 response_time_ms = int((time.time() - start_time) * 1000) 

780 SearchTracker.record_search( 

781 engine_name=engine_name, 

782 query=query, 

783 results_count=results_count, 

784 response_time_ms=response_time_ms, 

785 success=success, 

786 error_message=error_message, 

787 ) 

788 finally: 

789 # Clean up temporary search result storage 

790 for attr in self._temp_attributes(): 

791 if hasattr(self, attr): 

792 delattr(self, attr) 

793 # ALWAYS clean up search context, even if recording fails 

794 if context_was_set: 

795 clear_search_context() 

796 

797 def invoke(self, query: str) -> List[Dict[str, Any]]: 

798 """Compatibility method for LangChain tools""" 

799 return self.run(query) 

800 

801 def _filter_for_relevance( 

802 self, previews: List[Dict[str, Any]], query: str 

803 ) -> List[Dict[str, Any]]: 

804 """ 

805 Filter search results by relevance using the LLM. 

806 

807 Delegates to the ``relevance_filter`` module, which prompts the 

808 LLM for a plain-text list of relevant indices and parses them 

809 with a regex (no structured output). 

810 

811 Args: 

812 previews: List of preview dictionaries 

813 query: The original search query 

814 

815 Returns: 

816 Filtered list of preview dictionaries 

817 """ 

818 engine_name = type(self).__name__ 

819 

820 if not self.llm or len(previews) <= 1: 

821 logger.debug( 

822 f"[{engine_name}] Skipping relevance filter " 

823 f"(llm={'yes' if self.llm else 'no'}, " 

824 f"previews={len(previews)})" 

825 ) 

826 return previews 

827 

828 from .relevance_filter import filter_previews_for_relevance 

829 

830 return filter_previews_for_relevance( 

831 llm=self.llm, 

832 previews=previews, 

833 query=query, 

834 max_filtered_results=self.max_filtered_results, 

835 engine_name=engine_name, 

836 batch_size=self.relevance_filter_batch_size, 

837 max_parallel_batches=self.relevance_filter_max_parallel_batches, 

838 ) 

839 

840 # ========================================================================= 

841 # Shared Helper Methods for Subclasses 

842 # ========================================================================= 

843 

844 @staticmethod 

845 def _is_valid_api_key(api_key: Optional[str]) -> bool: 

846 """ 

847 Check if an API key is valid (not a placeholder value). 

848 

849 Args: 

850 api_key: The API key to validate 

851 

852 Returns: 

853 True if the key appears to be a real API key, False if it's a placeholder 

854 

855 Example: 

856 >>> BaseSearchEngine._is_valid_api_key("sk-abc123") 

857 True 

858 >>> BaseSearchEngine._is_valid_api_key("YOUR_API_KEY_HERE") 

859 False 

860 """ 

861 return not _is_api_key_placeholder(api_key) 

862 

863 @staticmethod 

864 def _extract_display_link(url: str, fallback: str = "") -> str: 

865 """Extract the netloc (domain) from a URL for display purposes. 

866 

867 Args: 

868 url: The URL to extract from 

869 fallback: Value to return on parse failure (default: empty string) 

870 

871 Returns: 

872 The netloc portion of the URL, or *fallback* if parsing fails. 

873 """ 

874 if not url: 

875 return fallback 

876 try: 

877 parsed = urlparse(url) 

878 return parsed.netloc or fallback 

879 except Exception: 

880 return fallback 

881 

882 @staticmethod 

883 def _clean_result_url(value: Any) -> str: 

884 """Normalize a raw search-result URL for the validity gate. 

885 

886 Coerces ``None`` (and any other falsy value) to ``""`` and any 

887 truthy value to ``str`` before stripping surrounding whitespace. 

888 

889 The strip is **load-bearing**, not merely cosmetic: several engines 

890 gate the URL on a ``url.lower().startswith(("http://", "https://"))`` 

891 prefix check (see ``_is_valid_search_result``) *before* it reaches 

892 the SSRF validator's own internal strip, so leading/trailing 

893 whitespace — common in HTML-scraped ``href`` attributes — would 

894 silently drop an otherwise-valid result. Cleaning once at extraction 

895 also keeps the URL tidy for logs and downstream consumers (preview 

896 ``id``/``link`` fields, etc.). 

897 

898 Args: 

899 value: The raw URL value from a search result, e.g. 

900 ``result.get("url")`` or a parsed HTML ``href`` attribute. 

901 

902 Returns: 

903 The whitespace-stripped URL string, or ``""`` if *value* is 

904 falsy (``None``, empty, etc.). 

905 """ 

906 if not value: 

907 return "" 

908 return str(value).strip() 

909 

910 def _resolve_api_key( 

911 self, 

912 api_key: Optional[str], 

913 setting_key: str, 

914 engine_name: str = "search engine", 

915 settings_snapshot: Optional[Dict[str, Any]] = None, 

916 ) -> str: 

917 """ 

918 Resolve an API key from multiple sources with priority order. 

919 

920 Environment variables are handled automatically by SettingsManager 

921 when building the settings snapshot, so they don't need to be 

922 checked separately here. 

923 

924 Priority order: 

925 1. Direct parameter (api_key argument) 

926 2. Settings snapshot (via setting_key) 

927 

928 Args: 

929 api_key: API key passed directly as parameter 

930 setting_key: Key to look up in settings snapshot (e.g., "search.brave_api_key") 

931 engine_name: Human-readable engine name for error messages 

932 settings_snapshot: Optional settings snapshot dict (uses self.settings_snapshot if not provided) 

933 

934 Returns: 

935 The resolved API key string 

936 

937 Raises: 

938 ValueError: If no valid API key is found from any source 

939 

940 Example: 

941 >>> engine._resolve_api_key( 

942 ... api_key=None, 

943 ... setting_key="search.brave_api_key", 

944 ... engine_name="Brave Search" 

945 ... ) 

946 "sk-abc123..." 

947 """ 

948 # Use instance settings snapshot if not provided 

949 if settings_snapshot is None: 

950 settings_snapshot = self.settings_snapshot 

951 

952 # Priority 1: Direct parameter 

953 if self._is_valid_api_key(api_key) and api_key is not None: 

954 return api_key.strip() 

955 

956 # Priority 2: Settings snapshot (includes env var overrides via SettingsManager) 

957 if settings_snapshot: 

958 settings_value = get_setting_from_snapshot( 

959 setting_key, 

960 default=None, 

961 settings_snapshot=settings_snapshot, 

962 ) 

963 if self._is_valid_api_key(settings_value): 

964 return settings_value.strip() if settings_value else "" 

965 

966 # No valid API key found 

967 masked_key = self._mask_api_key(str(api_key)) if api_key else "None" 

968 raise ValueError( 

969 f"No valid API key found for {engine_name}. " 

970 f"Checked: direct parameter ({masked_key}), " 

971 f"settings key '{setting_key}'. " 

972 f"Please provide a valid API key." 

973 ) 

974 

975 def _is_rate_limit_error( 

976 self, 

977 error: Union[Exception, str, int], 

978 additional_patterns: Optional[Set[str]] = None, 

979 ) -> bool: 

980 """ 

981 Detect if an error is a rate limit error. 

982 

983 Checks multiple sources for rate limit indicators: 

984 - HTTP status code 429 

985 - HTTPError response objects 

986 - Error messages containing rate limit phrases 

987 

988 Args: 

989 error: The error to check (Exception, string, or HTTP status code) 

990 additional_patterns: Optional set of additional patterns to match 

991 

992 Returns: 

993 True if the error appears to be a rate limit error 

994 

995 Example: 

996 >>> engine._is_rate_limit_error(429) 

997 True 

998 >>> engine._is_rate_limit_error("Rate limit exceeded") 

999 True 

1000 >>> engine._is_rate_limit_error(ValueError("Invalid input")) 

1001 False 

1002 """ 

1003 # Combine default and additional patterns 

1004 patterns = self.rate_limit_patterns.copy() 

1005 if additional_patterns: 

1006 patterns.update(additional_patterns) 

1007 

1008 # Check integer status code directly 

1009 if isinstance(error, int): 

1010 return error == 429 

1011 

1012 # Convert to string for pattern matching 

1013 error_str = "" 

1014 status_code = None 

1015 

1016 if isinstance(error, str): 

1017 error_str = error 

1018 elif isinstance(error, Exception): 

1019 error_str = str(error) 

1020 

1021 # Check for HTTP status code in common HTTP error types 

1022 if hasattr(error, "status_code"): 

1023 status_code = error.status_code 

1024 elif hasattr(error, "response"): 

1025 response = error.response 

1026 if hasattr(response, "status_code"): 

1027 status_code = response.status_code 

1028 

1029 # Check status code first 

1030 if status_code == 429: 

1031 return True 

1032 

1033 # Case-insensitive pattern matching 

1034 error_lower = error_str.lower() 

1035 for pattern in patterns: 

1036 if pattern.lower() in error_lower: 

1037 return True 

1038 

1039 return False 

1040 

1041 def _raise_if_rate_limit( 

1042 self, 

1043 error: Union[Exception, str, int], 

1044 additional_patterns: Optional[Set[str]] = None, 

1045 ) -> None: 

1046 """ 

1047 Raise RateLimitError if the given error is a rate limit error. 

1048 

1049 Convenience method that combines _is_rate_limit_error check with 

1050 raising RateLimitError. 

1051 

1052 Args: 

1053 error: The error to check 

1054 additional_patterns: Optional set of additional patterns to match 

1055 

1056 Raises: 

1057 RateLimitError: If the error is detected as a rate limit error 

1058 

1059 Example: 

1060 >>> try: 

1061 ... response = make_api_call() 

1062 ... except Exception as e: 

1063 ... engine._raise_if_rate_limit(e) 

1064 """ 

1065 if self._is_rate_limit_error(error, additional_patterns): 

1066 error_msg = str(error) if not isinstance(error, str) else error 

1067 raise RateLimitError( 

1068 f"Rate limit detected: {self._sanitize_error_message(error_msg)}" 

1069 ) 

1070 

1071 def _extract_full_result(self, item: Dict[str, Any]) -> Dict[str, Any]: 

1072 """ 

1073 Extract the full result from an item that may contain a _full_result key. 

1074 

1075 This is a helper for the default _get_full_content implementation. 

1076 It extracts data from the _full_result key if present, otherwise uses 

1077 the item directly, and removes the internal _full_result key. 

1078 

1079 Args: 

1080 item: A search result item that may contain a _full_result key 

1081 

1082 Returns: 

1083 A dictionary with the full result data, without the _full_result key 

1084 

1085 Example: 

1086 >>> engine._extract_full_result({"title": "A", "_full_result": {"title": "A", "content": "Full"}}) 

1087 {"title": "A", "content": "Full"} 

1088 """ 

1089 source = item.get("_full_result") 

1090 if source is None: 

1091 source = item 

1092 return {k: v for k, v in source.items() if k != "_full_result"} 

1093 

1094 def _get_full_content( 

1095 self, relevant_items: List[Dict[str, Any]] 

1096 ) -> List[Dict[str, Any]]: 

1097 """ 

1098 Get full content for the relevant items. 

1099 

1100 Default implementation extracts data from _full_result keys if present. 

1101 Subclasses can override this method to fetch additional content from 

1102 external sources (e.g., web scraping, API calls). 

1103 

1104 Args: 

1105 relevant_items: List of relevant preview dictionaries 

1106 

1107 Returns: 

1108 List of result dictionaries with full content 

1109 

1110 Example: 

1111 >>> engine._get_full_content([ 

1112 ... {"title": "A", "_full_result": {"title": "A", "content": "Full A"}}, 

1113 ... {"title": "B"} 

1114 ... ]) 

1115 [{"title": "A", "content": "Full A"}, {"title": "B"}] 

1116 """ 

1117 if not relevant_items: 

1118 return [] 

1119 return [self._extract_full_result(item) for item in relevant_items] 

1120 

1121 def _build_full_search_egress_context(self): 

1122 """Build an EgressContext from self.settings_snapshot for the 

1123 per-URL scope gate inside FullSearchResults. Returns None when a 

1124 context can't be built and disables full-content fetching when 

1125 policy evaluation fails on a supplied snapshot. 

1126 

1127 The previous "fail closed by returning None" comment was wrong: 

1128 full_search.py treats ``egress_context is None`` as 

1129 ``evaluate_url_fn = None`` and skips the per-URL scope check 

1130 entirely, leaving only SSRF — which allows public hosts under 

1131 PRIVATE_ONLY. When the snapshot was supplied but the policy is 

1132 unevaluable, we now turn full-content fetching off for this 

1133 engine instead. 

1134 """ 

1135 if not getattr(self, "settings_snapshot", None): 

1136 return None 

1137 try: 

1138 from ..security.egress.policy import ( 

1139 PolicyDeniedError, 

1140 context_from_snapshot, 

1141 ) 

1142 except ImportError: 

1143 logger.debug( 

1144 "egress_policy unavailable in search_engine_base; " 

1145 "FullSearchResults fetches will be SSRF-only" 

1146 ) 

1147 return None 

1148 

1149 primary_raw = unwrap_setting( 

1150 self.settings_snapshot.get("search.tool", DEFAULT_SEARCH_TOOL) 

1151 ) 

1152 try: 

1153 return context_from_snapshot( 

1154 self.settings_snapshot, primary_raw or DEFAULT_SEARCH_TOOL 

1155 ) 

1156 except (PolicyDeniedError, ValueError) as exc: 

1157 safe_msg = self._scrub_error(exc) 

1158 logger.bind(policy_audit=True).warning( 

1159 "Disabling include_full_content: egress policy could " 

1160 "not be evaluated for this engine", 

1161 reason=safe_msg, 

1162 ) 

1163 self.include_full_content = False 

1164 return None 

1165 

1166 def _init_full_search( 

1167 self, 

1168 web_search=None, 

1169 language="en", 

1170 max_results=10, 

1171 region=None, 

1172 time_period=None, 

1173 safe_search=None, 

1174 ): 

1175 """Initialize FullSearchResults if include_full_content is True. 

1176 

1177 Call this at the end of your __init__ after setting up your search wrapper. 

1178 

1179 Args: 

1180 web_search: The search wrapper/engine to pass to FullSearchResults 

1181 language: Language for search results 

1182 max_results: Maximum number of results 

1183 region: Region/country code for results 

1184 time_period: Time period filter 

1185 safe_search: Safe search setting (string value for FullSearchResults) 

1186 """ 

1187 if self.include_full_content and self.llm: 

1188 try: 

1189 from .engines.full_search import FullSearchResults 

1190 

1191 # Egress context for per-URL scope gating. Built once 

1192 # here (not per fetch) so the policy snapshot is the 

1193 # same one used by the factory PEP that authorized this 

1194 # engine. 

1195 egress_context = self._build_full_search_egress_context() 

1196 self.full_search = FullSearchResults( 

1197 llm=self.llm, 

1198 web_search=web_search, 

1199 language=language, 

1200 max_results=max_results, 

1201 region=region, 

1202 time=time_period, 

1203 safesearch=safe_search, 

1204 settings_snapshot=self.settings_snapshot, 

1205 egress_context=egress_context, 

1206 ) 

1207 except ImportError: 

1208 logger.warning( 

1209 "FullSearchResults not available. " 

1210 "Full content retrieval disabled." 

1211 ) 

1212 self.include_full_content = False 

1213 

1214 def _temp_attributes(self): 

1215 """Return list of temporary attribute names to clean up after run(). 

1216 

1217 Override in subclasses that store additional temporary data. 

1218 """ 

1219 return ["_search_results"] 

1220 

1221 def _sanitize_error_message(self, message: str) -> str: 

1222 """ 

1223 Remove/mask API keys, tokens, and secrets from error messages. 

1224 

1225 Uses pattern matching for common credential formats. 

1226 

1227 Args: 

1228 message: The error message to sanitize 

1229 

1230 Returns: 

1231 Sanitized message with sensitive data redacted 

1232 

1233 Example: 

1234 >>> engine._sanitize_error_message( 

1235 ... "Error with key sk-abc123def456ghi789jkl012" 

1236 ... ) 

1237 "Error with key [REDACTED_KEY]" 

1238 """ 

1239 return sanitize_error_message(message) 

1240 

1241 def _scrub_error(self, error: Union[BaseException, str]) -> str: 

1242 """Return a log/DB-safe rendering of *error*. 

1243 

1244 Delegates the "dual-scrub" to :func:`~..security.log_sanitizer.scrub_error`, 

1245 resolving this engine's known literal secret values from 

1246 ``_secret_attrs``. *error* may be an exception or a pre-built 

1247 message string. 

1248 

1249 Use this at every catch site that logs or persists an exception so 

1250 the two scrub passes can never drift apart per-engine. 

1251 """ 

1252 return scrub_error( 

1253 error, *(getattr(self, name, None) for name in self._secret_attrs) 

1254 ) 

1255 

1256 def _mask_api_key(self, api_key: str, visible_chars: int = 4) -> str: 

1257 """ 

1258 Mask an API key for safe logging, showing only first and last characters. 

1259 

1260 Args: 

1261 api_key: The API key to mask 

1262 visible_chars: Number of characters to show at start and end 

1263 

1264 Returns: 

1265 Masked API key in format "sk-1...nop" or "***" for short keys 

1266 

1267 Example: 

1268 >>> engine._mask_api_key("sk-abcdefghijklmnop123456") 

1269 "sk-a...3456" 

1270 >>> engine._mask_api_key("short") 

1271 "***" 

1272 """ 

1273 if not api_key: 

1274 return "***" 

1275 

1276 api_key = str(api_key).strip() 

1277 

1278 if len(api_key) <= visible_chars * 2: 

1279 return "***" 

1280 

1281 return f"{api_key[:visible_chars]}...{api_key[-visible_chars:]}" 

1282 

1283 @abstractmethod 

1284 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

1285 """ 

1286 Get preview information (titles, summaries) for initial search results. 

1287 

1288 Args: 

1289 query: The search query 

1290 

1291 Returns: 

1292 List of preview dictionaries with at least 'id', 'title', and 'snippet' keys 

1293 """ 

1294 pass 

1295 

1296 def close(self) -> None: 

1297 """ 

1298 Close any resources held by this search engine. 

1299 

1300 Subclasses with HTTP sessions or other resources should override this. 

1301 The base implementation safely closes any 'session' attribute if present 

1302 and closes both preview and content filters that hold resources. 

1303 """ 

1304 from ..utilities.resource_utils import safe_close 

1305 

1306 if hasattr(self, "session") and self.session is not None: 

1307 safe_close(self.session, "HTTP session") 

1308 # Close preview filters as well as content filters — the journal 

1309 # reputation filter is registered as a preview filter on academic 

1310 # engines (arxiv, pubmed, openalex, nasa_ads, semantic_scholar) 

1311 # and holds a SearXNG engine + LLM client that need releasing. 

1312 if hasattr(self, "_preview_filters"): 

1313 for preview_filter in self._preview_filters: 1313 ↛ 1314line 1313 didn't jump to line 1314 because the loop on line 1313 never started

1314 safe_close(preview_filter, "preview filter") 

1315 if hasattr(self, "_content_filters"): 

1316 for content_filter in self._content_filters: 1316 ↛ 1317line 1316 didn't jump to line 1317 because the loop on line 1316 never started

1317 safe_close(content_filter, "content filter") 

1318 

1319 def __enter__(self): 

1320 """Support context manager usage.""" 

1321 return self 

1322 

1323 def __exit__(self, exc_type, exc_val, exc_tb): 

1324 """Cleanup on context exit.""" 

1325 self.close() 

1326 return False