Spaces:
Sleeping
Fix the defects a mutation probe found, and the architecture that hid them (#6)
Browse filesA mutation probe of the existing suite killed 5 of 6 mutants aimed at pure
functions and 1 of 7 aimed at the I/O boundary - deleting the API-key
middleware entirely left it green, as did replacing the Bing search URL
with a constant. Line coverage read 93% and could not see the difference.
Fixes every defect that hid behind that gap: the blocking DuckDuckGo call
that froze the event loop on the primary search path, an empty query list
500ing all six search endpoints, a non-ASCII API key crashing the auth
check, upstream failures reported as "patent not found" (which
MCP_INSTRUCTIONS tells agents to believe and not retry), unbounded bulk
fan-out, and three MCP tools advertising no output schema.
Then the cause: orchestration policy moved out of the route handlers into
services.py, with its stateful collaborators injected via Depends. Also
adds ruff to CI, closes the httpx client in the lifespan, and stops the
circuit breaker treating "zero results" as evidence a backend is down.
97 -> 238 tests, app.py 65% -> 97% coverage, mutants killed 6/13 -> 18/18.
- .github/workflows/deploy-to-hf.yml +3 -0
- .github/workflows/test.yml +3 -0
- .gitignore +4 -1
- CONTRIBUTING.md +53 -3
- README.md +5 -3
- app.py +110 -179
- ops.py +12 -1
- pyproject.toml +17 -0
- requirements-dev.txt +2 -0
- scrap.py +15 -3
- serp.py +95 -23
- services.py +337 -0
- tests/conftest.py +26 -0
- tests/helpers.py +1 -64
- tests/test_app.py +157 -223
- tests/test_auth.py +180 -0
- tests/test_input_bounds.py +149 -0
- tests/test_mcp_server.py +102 -0
- tests/test_patent_fallback.py +232 -0
- tests/test_scrap.py +12 -0
- tests/test_search_service.py +390 -0
- tests/test_serp_ddg_concurrency.py +72 -0
- tests/test_serp_navigation.py +156 -0
- tests/test_serp_playwright.py +108 -42
- tests/test_serp_urls.py +78 -0
- tests/test_utils.py +25 -9
- utils.py +13 -5
|
@@ -24,6 +24,9 @@ jobs:
|
|
| 24 |
- name: Install Playwright's Chromium
|
| 25 |
run: playwright install --with-deps chromium
|
| 26 |
|
|
|
|
|
|
|
|
|
|
| 27 |
- name: Run tests
|
| 28 |
run: pytest
|
| 29 |
|
|
|
|
| 24 |
- name: Install Playwright's Chromium
|
| 25 |
run: playwright install --with-deps chromium
|
| 26 |
|
| 27 |
+
- name: Lint
|
| 28 |
+
run: ruff check .
|
| 29 |
+
|
| 30 |
- name: Run tests
|
| 31 |
run: pytest
|
| 32 |
|
|
@@ -29,5 +29,8 @@ jobs:
|
|
| 29 |
- name: Install Playwright's Chromium
|
| 30 |
run: playwright install --with-deps chromium
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
- name: Run tests
|
| 33 |
run: pytest
|
|
|
|
| 29 |
- name: Install Playwright's Chromium
|
| 30 |
run: playwright install --with-deps chromium
|
| 31 |
|
| 32 |
+
- name: Lint
|
| 33 |
+
run: ruff check .
|
| 34 |
+
|
| 35 |
- name: Run tests
|
| 36 |
run: pytest
|
|
@@ -1,4 +1,7 @@
|
|
| 1 |
__pycache__
|
| 2 |
.vscode
|
| 3 |
.venv
|
| 4 |
-
.env
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
__pycache__
|
| 2 |
.vscode
|
| 3 |
.venv
|
| 4 |
+
.env
|
| 5 |
+
.coverage
|
| 6 |
+
htmlcov
|
| 7 |
+
.ruff_cache
|
|
@@ -26,10 +26,11 @@ dependency update broke this."
|
|
| 26 |
## Running tests
|
| 27 |
|
| 28 |
```bash
|
|
|
|
| 29 |
pytest
|
| 30 |
```
|
| 31 |
|
| 32 |
-
|
| 33 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 34 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 35 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
@@ -54,6 +55,34 @@ bug here — fix it the same way as anything else (see below): update the
|
|
| 54 |
fixture HTML to match the new real markup, confirm the test fails against
|
| 55 |
the current selectors, fix the selectors, confirm green.
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
## The development loop (TDD, not test-after)
|
| 58 |
|
| 59 |
Every change in this codebase's history followed the same loop, and new
|
|
@@ -77,6 +106,22 @@ changes should too:
|
|
| 77 |
confirm the test actually catches it, then revert. This caught a real
|
| 78 |
gap once already: a circuit breaker test that looked correct was
|
| 79 |
actually vacuous until this step exposed it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
6. **Watch for shared mutable state across tests.** A few characterization
|
| 81 |
tests in this codebase turned out to be recording real state into a
|
| 82 |
module-level singleton (a circuit breaker) with nothing resetting it
|
|
@@ -93,8 +138,13 @@ changes should too:
|
|
| 93 |
|
| 94 |
## CI / deployment
|
| 95 |
|
| 96 |
-
- **`.github/workflows/test.yml`** runs
|
| 97 |
-
request against `main`, so a change gets feedback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
- **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
|
| 99 |
(`test` job) on every push to `main`, and only then (`needs: test`)
|
| 100 |
pushes the repo to the Hugging Face Space (`deploy` job) with
|
|
|
|
| 26 |
## Running tests
|
| 27 |
|
| 28 |
```bash
|
| 29 |
+
ruff check . # same lint CI runs
|
| 30 |
pytest
|
| 31 |
```
|
| 32 |
|
| 33 |
+
238 tests, a few seconds, fully offline. All outbound HTTP is mocked with
|
| 34 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 35 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 36 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
|
|
| 55 |
fixture HTML to match the new real markup, confirm the test fails against
|
| 56 |
the current selectors, fix the selectors, confirm green.
|
| 57 |
|
| 58 |
+
## How the code is laid out
|
| 59 |
+
|
| 60 |
+
- **`app.py`** is HTTP wiring only: request validation, dependency
|
| 61 |
+
providers, the lifespan, and mapping domain errors onto status codes.
|
| 62 |
+
- **`services.py`** holds the orchestration policy - which backend to try,
|
| 63 |
+
in what order, when to fall back, what counts as evidence a backend is
|
| 64 |
+
unhealthy, and when a patent is genuinely absent rather than merely
|
| 65 |
+
unreachable. None of that is HTTP-specific, and keeping it out of route
|
| 66 |
+
handlers is why most of its tests need no ASGI client.
|
| 67 |
+
- **`serp.py` / `scrap.py` / `ops.py`** are the backends. Each scraper is
|
| 68 |
+
split into a pure URL builder, a navigation step, and an `_extract_*`
|
| 69 |
+
function taking an already-loaded page, so all three can be tested
|
| 70 |
+
separately.
|
| 71 |
+
- **`utils.py` / `circuit_breaker.py`** are dependency-free helpers.
|
| 72 |
+
|
| 73 |
+
The services take their stateful collaborators (HTTP client, browser,
|
| 74 |
+
circuit breaker, OPS credentials) as constructor arguments. That is
|
| 75 |
+
deliberate and worth preserving: it is what lets a test build a service
|
| 76 |
+
with its own circuit breaker instead of resetting a process-wide singleton
|
| 77 |
+
between tests, and hand in a stand-in for the OPS credentials instead of
|
| 78 |
+
assigning to private attributes on the real token manager. `app.py`'s
|
| 79 |
+
`get_search_service` / `get_patent_service` providers exist so
|
| 80 |
+
`app.dependency_overrides` can replace them wholesale in a test.
|
| 81 |
+
|
| 82 |
+
The stateless backend functions stay module-level imports in
|
| 83 |
+
`services.py`; monkeypatching one of those in a test is fine, because they
|
| 84 |
+
hold no state to leak between tests.
|
| 85 |
+
|
| 86 |
## The development loop (TDD, not test-after)
|
| 87 |
|
| 88 |
Every change in this codebase's history followed the same loop, and new
|
|
|
|
| 106 |
confirm the test actually catches it, then revert. This caught a real
|
| 107 |
gap once already: a circuit breaker test that looked correct was
|
| 108 |
actually vacuous until this step exposed it.
|
| 109 |
+
|
| 110 |
+
Applying this systematically is worth a pass of its own, and one such
|
| 111 |
+
pass is where most of the tests added since came from. Line coverage
|
| 112 |
+
was 93% and hid the problem completely: the suite killed five of six
|
| 113 |
+
mutants aimed at pure functions, and one of seven aimed at the I/O
|
| 114 |
+
boundary. Deleting the entire API-key middleware left it green, as did
|
| 115 |
+
replacing the Bing search URL with a constant. The lesson worth keeping
|
| 116 |
+
is *where* to point this technique - at the code that touches the
|
| 117 |
+
outside world, not the code that's easy to test.
|
| 118 |
+
|
| 119 |
+
A useful heuristic fell out of it: if a property has no behavioural
|
| 120 |
+
signature at all, no black-box test can pin it. `==` and
|
| 121 |
+
`secrets.compare_digest` return the same answer for every input and
|
| 122 |
+
differ only in timing, so that one test asserts structurally that the
|
| 123 |
+
comparison goes through `compare_digest`. Reach for that only when
|
| 124 |
+
there is genuinely nothing observable to assert on.
|
| 125 |
6. **Watch for shared mutable state across tests.** A few characterization
|
| 126 |
tests in this codebase turned out to be recording real state into a
|
| 127 |
module-level singleton (a circuit breaker) with nothing resetting it
|
|
|
|
| 138 |
|
| 139 |
## CI / deployment
|
| 140 |
|
| 141 |
+
- **`.github/workflows/test.yml`** runs `ruff check .` and then the full
|
| 142 |
+
suite on every pull request against `main`, so a change gets feedback
|
| 143 |
+
before merge. The lint rule set (see `pyproject.toml`) is deliberately
|
| 144 |
+
small - pyflakes-equivalent checks plus bugbear - because the first pass
|
| 145 |
+
over this repo found a duplicate import shadowing another, four unused
|
| 146 |
+
imports and an f-string with no placeholders. Widen it when the team
|
| 147 |
+
wants more rather than starting broad and accumulating ignores.
|
| 148 |
- **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
|
| 149 |
(`test` job) on every push to `main`, and only then (`needs: test`)
|
| 150 |
pushes the repo to the Hugging Face Space (`deploy` job) with
|
|
@@ -19,13 +19,15 @@ pinned: true
|
|
| 19 |
```
|
| 20 |
pip install -r requirements.txt -r requirements-dev.txt
|
| 21 |
playwright install chromium # if not already present on the machine
|
|
|
|
| 22 |
pytest
|
| 23 |
```
|
| 24 |
|
| 25 |
The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
|
| 26 |
-
scrapers against local fixture HTML
|
| 27 |
-
`tests/
|
| 28 |
-
or risk tripping, any site's anti-bot
|
|
|
|
| 29 |
|
| 30 |
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
|
| 31 |
(TDD workflow, dependency pinning policy, CI/deployment) behind this project.
|
|
|
|
| 19 |
```
|
| 20 |
pip install -r requirements.txt -r requirements-dev.txt
|
| 21 |
playwright install chromium # if not already present on the machine
|
| 22 |
+
ruff check .
|
| 23 |
pytest
|
| 24 |
```
|
| 25 |
|
| 26 |
The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
|
| 27 |
+
scrapers against local fixture HTML loaded straight into a real headless
|
| 28 |
+
Chromium - see the `page_factory` fixture in `tests/conftest.py` - so it runs
|
| 29 |
+
fully offline and doesn't depend on, or risk tripping, any site's anti-bot
|
| 30 |
+
measures.
|
| 31 |
|
| 32 |
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
|
| 33 |
(TDD workflow, dependency pinning policy, CI/deployment) behind this project.
|
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
import logging
|
| 3 |
import os
|
| 4 |
import secrets
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
from typing import Annotated, Optional
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
from fastapi.routing import APIRouter
|
| 10 |
-
import
|
| 11 |
-
from httpx import HTTPStatusError
|
| 12 |
from pydantic import BaseModel, Field, StringConstraints
|
| 13 |
-
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
| 14 |
-
import uvicorn
|
| 15 |
|
| 16 |
-
from circuit_breaker import CircuitBreaker
|
| 17 |
-
from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async, scrap_patent_bulk_async
|
| 18 |
-
from serp import PATENT_ID_CORE, SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
|
| 19 |
-
from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
|
| 20 |
-
from utils import log_gathered_exceptions
|
| 21 |
from mcp_server import mount_mcp_server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Anchored version of serp.py's PATENT_ID_CORE: validates a whole
|
| 24 |
# user-supplied patent id rather than finding one inside free text. Rejects
|
|
@@ -26,6 +26,11 @@ from mcp_server import mount_mcp_server
|
|
| 26 |
# instead of it reaching Google Patents/OPS as a confusing request.
|
| 27 |
PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")]
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
logging.basicConfig(
|
| 30 |
level=logging.INFO,
|
| 31 |
format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
|
|
@@ -36,10 +41,19 @@ logging.basicConfig(
|
|
| 36 |
playwright = None
|
| 37 |
pw_browser: Optional[Browser] = None
|
| 38 |
|
| 39 |
-
# httpx client
|
|
|
|
|
|
|
| 40 |
httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
|
| 41 |
max_connections=30, max_keepalive_connections=20))
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
# ===================== Optional API key protection =====================
|
| 44 |
# Unset by default, which keeps the API exactly as open as before. Set
|
| 45 |
# SERPENT_API_KEY on the deployment to require every request (REST and MCP
|
|
@@ -89,23 +103,78 @@ async def api_lifespan(app: FastAPI):
|
|
| 89 |
await pw_browser.close()
|
| 90 |
if playwright:
|
| 91 |
await playwright.stop()
|
|
|
|
| 92 |
|
| 93 |
app = FastAPI(lifespan=api_lifespan, docs_url="/",
|
| 94 |
title="SERPent", description=_load_docs())
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
@app.middleware("http")
|
| 98 |
async def api_key_guard(request: Request, call_next):
|
| 99 |
"""No-op unless SERPENT_API_KEY is set; then gates every path but the docs."""
|
| 100 |
if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS:
|
| 101 |
supplied = _extract_supplied_key(request)
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return JSONResponse(
|
| 104 |
{"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."},
|
| 105 |
status_code=401,
|
| 106 |
)
|
| 107 |
return await call_next(request)
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# Router for scrapping related endpoints
|
| 110 |
scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"])
|
| 111 |
# Router for SERP-scrapping related endpoints
|
|
@@ -116,243 +185,105 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
|
|
| 116 |
# ===================== Search endpoints =====================
|
| 117 |
|
| 118 |
|
| 119 |
-
def _shape_serp_results(results: list) -> SerpResults:
|
| 120 |
-
"""Flatten a list of per-query result-lists (or Exceptions, from a
|
| 121 |
-
`return_exceptions=True` gather) into one SerpResults, surfacing the
|
| 122 |
-
last error only when every query failed.
|
| 123 |
-
"""
|
| 124 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 125 |
-
flattened_results = [
|
| 126 |
-
item for sublist in filtered_results for item in sublist]
|
| 127 |
-
|
| 128 |
-
if len(filtered_results) == 0:
|
| 129 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 130 |
-
|
| 131 |
-
return SerpResults(results=flattened_results, error=None)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
|
| 135 |
-
"""Run `fn(query, n_results)` concurrently for every query in `params`,
|
| 136 |
-
log any failures, and shape the result the way every simple /serp and
|
| 137 |
-
/ops search endpoint below expects.
|
| 138 |
-
"""
|
| 139 |
-
results = await asyncio.gather(
|
| 140 |
-
*[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
|
| 141 |
-
log_gathered_exceptions(results, context, params)
|
| 142 |
-
return _shape_serp_results(results)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
@serp_router.post("/search_scholar")
|
| 146 |
-
async def search_google_scholar(params: SerpQuery):
|
| 147 |
"""Queries google scholar for the specified query"""
|
| 148 |
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 149 |
-
return await
|
| 150 |
-
lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
|
| 151 |
|
| 152 |
|
| 153 |
@serp_router.post("/search_arxiv")
|
| 154 |
-
async def search_arxiv(params: SerpQuery):
|
| 155 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 156 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 157 |
-
return await
|
| 158 |
-
lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
|
| 159 |
|
| 160 |
|
| 161 |
@serp_router.post("/search_patents")
|
| 162 |
-
async def search_patents(params: SerpQuery) -> SerpResults:
|
| 163 |
"""Searches google patents for the specified queries and returns the found documents.
|
| 164 |
|
| 165 |
Falls back to the EPO OPS API for any query Google Patents returns nothing
|
| 166 |
for, when OPS credentials are configured.
|
| 167 |
"""
|
| 168 |
logging.info(f"Searching Google Patents for queries: {params.queries}")
|
| 169 |
-
|
| 170 |
-
log_gathered_exceptions(results, "google patent search", params)
|
| 171 |
-
|
| 172 |
-
# Fall back to OPS for queries that errored or returned no results.
|
| 173 |
-
if ops_token_manager.configured:
|
| 174 |
-
for i, res in enumerate(results):
|
| 175 |
-
if isinstance(res, Exception) or not res:
|
| 176 |
-
try:
|
| 177 |
-
logging.info(
|
| 178 |
-
f"Google Patents empty for `{params.queries[i]}`, trying OPS.")
|
| 179 |
-
results[i] = await ops_search(httpx_client, params.queries[i], params.n_results)
|
| 180 |
-
except Exception as e:
|
| 181 |
-
logging.warning(f"OPS fallback failed: {e}")
|
| 182 |
-
|
| 183 |
-
return _shape_serp_results(results)
|
| 184 |
|
| 185 |
|
| 186 |
@serp_router.post("/search_brave")
|
| 187 |
-
async def search_brave(params: SerpQuery) -> SerpResults:
|
| 188 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 189 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 190 |
-
return await
|
| 191 |
-
lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
|
| 192 |
|
| 193 |
|
| 194 |
@serp_router.post("/search_bing")
|
| 195 |
-
async def search_bing(params: SerpQuery) -> SerpResults:
|
| 196 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 197 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 198 |
-
return await
|
| 199 |
-
lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
|
| 200 |
|
| 201 |
|
| 202 |
@serp_router.post("/search_duck")
|
| 203 |
-
async def search_duck(params: SerpQuery) -> SerpResults:
|
| 204 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 205 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 206 |
-
return await
|
| 207 |
-
lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
# Shared across all queries and requests: once a backend has failed
|
| 211 |
-
# `failure_threshold` times in a row, skip it for `cooldown_seconds` instead
|
| 212 |
-
# of attempting (and paying the timeout cost of) another call that's very
|
| 213 |
-
# likely to fail - and, more importantly, stop hammering a backend that may
|
| 214 |
-
# already be rate-limiting or blocking this deployment's IP.
|
| 215 |
-
_backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
| 219 |
-
"""Try DDG, then Brave, then Bing for a single query; stop at the first success."""
|
| 220 |
-
backends = [
|
| 221 |
-
("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
|
| 222 |
-
("Brave Search", lambda: query_brave_search(pw_browser, q, n_results)),
|
| 223 |
-
("Bing", lambda: query_bing_search(pw_browser, q, n_results)),
|
| 224 |
-
]
|
| 225 |
-
last_error: Optional[Exception] = None
|
| 226 |
-
for name, call in backends:
|
| 227 |
-
try:
|
| 228 |
-
_backend_circuit_breaker.before_call(name)
|
| 229 |
-
except CircuitOpenError as e:
|
| 230 |
-
logging.info(f"Skipping {name} for query `{q}`: {e}")
|
| 231 |
-
last_error = e
|
| 232 |
-
continue
|
| 233 |
-
|
| 234 |
-
try:
|
| 235 |
-
logging.info(f"Querying {name} with query: `{q}`")
|
| 236 |
-
result = await call()
|
| 237 |
-
_backend_circuit_breaker.record_success(name)
|
| 238 |
-
return q, result, None
|
| 239 |
-
except Exception as e:
|
| 240 |
-
logging.error(f"Failed to query {name} with query `{q}`: {e}")
|
| 241 |
-
_backend_circuit_breaker.record_failure(name)
|
| 242 |
-
last_error = e
|
| 243 |
-
|
| 244 |
-
return q, [], f"All backends failed for query '{q}': {last_error}"
|
| 245 |
|
| 246 |
|
| 247 |
@serp_router.post("/search")
|
| 248 |
-
async def search(params: SerpQuery):
|
| 249 |
"""Attempts to search the specified queries using ALL backends"""
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
results: list[dict] = []
|
| 253 |
-
errors: list[str] = []
|
| 254 |
-
for _q, res, err in outcomes:
|
| 255 |
-
results.extend(res)
|
| 256 |
-
if err:
|
| 257 |
-
errors.append(err)
|
| 258 |
-
|
| 259 |
-
if len(results) == 0:
|
| 260 |
-
return SerpResults(results=[], error="; ".join(errors) if errors else "All backends are rate-limited.")
|
| 261 |
-
|
| 262 |
-
return SerpResults(results=results, error="; ".join(errors) if errors else None)
|
| 263 |
|
| 264 |
# =========================== Scrapping endpoints ===========================
|
| 265 |
|
| 266 |
|
| 267 |
@scrap_router.get("/scrap_patent/{patent_id}")
|
| 268 |
-
async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
|
| 269 |
"""Scraps the specified patent from Google Patents.
|
| 270 |
|
| 271 |
Falls back to the EPO OPS API (which covers patents missing from Google
|
| 272 |
Patents) when the scrape fails and OPS credentials are configured.
|
| 273 |
"""
|
| 274 |
-
|
| 275 |
-
return await scrap_patent_async(httpx_client, f"https://patents.google.com/patent/{patent_id}/en")
|
| 276 |
-
except HTTPStatusError as e:
|
| 277 |
-
logging.warning(
|
| 278 |
-
f"Google Patents returned {e.response.status_code} for {patent_id}.")
|
| 279 |
-
except Exception as e:
|
| 280 |
-
logging.warning(f"Failed to scrap patent {patent_id}: {e}")
|
| 281 |
-
|
| 282 |
-
if not ops_token_manager.configured:
|
| 283 |
-
raise HTTPException(
|
| 284 |
-
status_code=404,
|
| 285 |
-
detail=f"Patent '{patent_id}' not found on Google Patents (EPO OPS fallback not configured).")
|
| 286 |
-
|
| 287 |
-
try:
|
| 288 |
-
logging.info(f"Trying OPS for patent {patent_id}.")
|
| 289 |
-
return await ops_scrap_patent(httpx_client, patent_id)
|
| 290 |
-
except HTTPStatusError as e:
|
| 291 |
-
if e.response.status_code == 404:
|
| 292 |
-
raise HTTPException(
|
| 293 |
-
status_code=404,
|
| 294 |
-
detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.")
|
| 295 |
-
raise HTTPException(
|
| 296 |
-
status_code=502,
|
| 297 |
-
detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
|
| 298 |
-
except Exception as e:
|
| 299 |
-
logging.warning(f"OPS fallback failed for {patent_id}: {e}")
|
| 300 |
-
raise HTTPException(
|
| 301 |
-
status_code=502,
|
| 302 |
-
detail=f"EPO OPS request failed for '{patent_id}': {e}")
|
| 303 |
|
| 304 |
|
| 305 |
class ScrapPatentsRequest(BaseModel):
|
| 306 |
"""Request model for scrapping multiple patents."""
|
| 307 |
patent_ids: list[PatentId] = Field(...,
|
|
|
|
|
|
|
| 308 |
description="List of patent IDs to scrap")
|
| 309 |
|
| 310 |
|
| 311 |
@scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
|
| 312 |
-
async def scrap_patents(params: ScrapPatentsRequest
|
|
|
|
| 313 |
"""Scraps multiple patents from Google Patents."""
|
| 314 |
-
|
| 315 |
-
return patents
|
| 316 |
|
| 317 |
# =========================== EPO OPS endpoints ===========================
|
| 318 |
|
| 319 |
|
| 320 |
@ops_router.post("/search")
|
| 321 |
-
async def ops_keyword_search(params: SerpQuery) -> SerpResults:
|
| 322 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 323 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 324 |
-
return await
|
| 325 |
-
lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
|
| 326 |
|
| 327 |
|
| 328 |
@ops_router.get("/scrap_patent/{patent_id}")
|
| 329 |
-
async def ops_get_patent(patent_id: PatentId) -> PatentScrapResult:
|
| 330 |
"""Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
|
| 331 |
-
|
| 332 |
-
raise HTTPException(
|
| 333 |
-
status_code=503,
|
| 334 |
-
detail="EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
|
| 335 |
-
try:
|
| 336 |
-
return await ops_scrap_patent(httpx_client, patent_id)
|
| 337 |
-
except OPSNotConfigured:
|
| 338 |
-
raise HTTPException(status_code=503, detail="EPO OPS is not configured.")
|
| 339 |
-
except HTTPStatusError as e:
|
| 340 |
-
if e.response.status_code == 404:
|
| 341 |
-
raise HTTPException(
|
| 342 |
-
status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.")
|
| 343 |
-
raise HTTPException(
|
| 344 |
-
status_code=502,
|
| 345 |
-
detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
|
| 346 |
-
except Exception as e:
|
| 347 |
-
logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
|
| 348 |
-
raise HTTPException(
|
| 349 |
-
status_code=502, detail=f"EPO OPS request failed for '{patent_id}': {e}")
|
| 350 |
|
| 351 |
|
| 352 |
@ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
|
| 353 |
-
async def ops_get_patents_bulk(params: ScrapPatentsRequest
|
|
|
|
| 354 |
"""Retrieves multiple patents via EPO OPS."""
|
| 355 |
-
return await
|
| 356 |
|
| 357 |
# ===============================================================================
|
| 358 |
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
import os
|
| 3 |
import secrets
|
| 4 |
from contextlib import asynccontextmanager
|
| 5 |
from typing import Annotated, Optional
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
import uvicorn
|
| 9 |
+
from fastapi import Depends, FastAPI, Request
|
| 10 |
from fastapi.responses import JSONResponse
|
| 11 |
from fastapi.routing import APIRouter
|
| 12 |
+
from playwright.async_api import Browser, async_playwright
|
|
|
|
| 13 |
from pydantic import BaseModel, Field, StringConstraints
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
from circuit_breaker import CircuitBreaker
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from mcp_server import mount_mcp_server
|
| 17 |
+
from ops import OPSBulkResponse, token_manager as ops_token_manager
|
| 18 |
+
from scrap import PatentScrapBulkResponse, PatentScrapResult
|
| 19 |
+
from serp import PATENT_ID_CORE, SerpQuery, SerpResults
|
| 20 |
+
from services import (OPSUnconfigured, PatentNotFound, PatentService,
|
| 21 |
+
SearchService, UpstreamUnavailable)
|
| 22 |
|
| 23 |
# Anchored version of serp.py's PATENT_ID_CORE: validates a whole
|
| 24 |
# user-supplied patent id rather than finding one inside free text. Rejects
|
|
|
|
| 26 |
# instead of it reaching Google Patents/OPS as a confusing request.
|
| 27 |
PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")]
|
| 28 |
|
| 29 |
+
# Same reasoning as MAX_QUERIES_PER_REQUEST in serp.py: one accepted
|
| 30 |
+
# request must not be able to queue unbounded outbound work. The OPS
|
| 31 |
+
# variant is the expensive one - three requests per id.
|
| 32 |
+
MAX_BULK_PATENT_IDS = 50
|
| 33 |
+
|
| 34 |
logging.basicConfig(
|
| 35 |
level=logging.INFO,
|
| 36 |
format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
|
|
|
|
| 41 |
playwright = None
|
| 42 |
pw_browser: Optional[Browser] = None
|
| 43 |
|
| 44 |
+
# httpx client. Constructed at import so the dependency providers below can
|
| 45 |
+
# close over it, but its lifetime is owned by `api_lifespan`, which closes
|
| 46 |
+
# it on shutdown alongside the browser.
|
| 47 |
httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
|
| 48 |
max_connections=30, max_keepalive_connections=20))
|
| 49 |
|
| 50 |
+
# Shared across all queries and requests: once a backend has failed
|
| 51 |
+
# `failure_threshold` times in a row, skip it for `cooldown_seconds` instead
|
| 52 |
+
# of attempting (and paying the timeout cost of) another call that's very
|
| 53 |
+
# likely to fail - and, more importantly, stop hammering a backend that may
|
| 54 |
+
# already be rate-limiting or blocking this deployment's IP.
|
| 55 |
+
_backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
|
| 56 |
+
|
| 57 |
# ===================== Optional API key protection =====================
|
| 58 |
# Unset by default, which keeps the API exactly as open as before. Set
|
| 59 |
# SERPENT_API_KEY on the deployment to require every request (REST and MCP
|
|
|
|
| 103 |
await pw_browser.close()
|
| 104 |
if playwright:
|
| 105 |
await playwright.stop()
|
| 106 |
+
await httpx_client.aclose()
|
| 107 |
|
| 108 |
app = FastAPI(lifespan=api_lifespan, docs_url="/",
|
| 109 |
title="SERPent", description=_load_docs())
|
| 110 |
|
| 111 |
|
| 112 |
+
# ============================ service dependencies ============================
|
| 113 |
+
# The orchestration itself lives in services.py; these just wire the app's
|
| 114 |
+
# collaborators into it. Overriding one of these via
|
| 115 |
+
# `app.dependency_overrides` is how a test supplies its own circuit breaker
|
| 116 |
+
# or OPS credentials, rather than reassigning module globals.
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_search_service() -> SearchService:
|
| 120 |
+
return SearchService(
|
| 121 |
+
http_client=httpx_client,
|
| 122 |
+
# A provider, not the browser itself: the lifespan starts it after
|
| 123 |
+
# import, and leaves it None when Playwright fails to start.
|
| 124 |
+
browser_provider=lambda: pw_browser,
|
| 125 |
+
circuit_breaker=_backend_circuit_breaker,
|
| 126 |
+
ops_tokens=ops_token_manager,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def get_patent_service() -> PatentService:
|
| 131 |
+
return PatentService(http_client=httpx_client, ops_tokens=ops_token_manager)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
| 135 |
+
PatentServiceDep = Annotated[PatentService, Depends(get_patent_service)]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
@app.middleware("http")
|
| 139 |
async def api_key_guard(request: Request, call_next):
|
| 140 |
"""No-op unless SERPENT_API_KEY is set; then gates every path but the docs."""
|
| 141 |
if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS:
|
| 142 |
supplied = _extract_supplied_key(request)
|
| 143 |
+
# Compare as bytes: compare_digest raises TypeError on str arguments
|
| 144 |
+
# containing non-ASCII characters, and Starlette decodes headers as
|
| 145 |
+
# latin-1, so an accented byte in the header would otherwise turn
|
| 146 |
+
# this check into an unhandled 500 for any anonymous caller.
|
| 147 |
+
if not supplied or not secrets.compare_digest(
|
| 148 |
+
supplied.encode("utf-8"), SERPENT_API_KEY.encode("utf-8")):
|
| 149 |
return JSONResponse(
|
| 150 |
{"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."},
|
| 151 |
status_code=401,
|
| 152 |
)
|
| 153 |
return await call_next(request)
|
| 154 |
|
| 155 |
+
|
| 156 |
+
# ========================= domain errors -> status codes =========================
|
| 157 |
+
# 404 is a claim that the patent does not exist, and MCP_INSTRUCTIONS tells
|
| 158 |
+
# agents to believe it and move on without retrying - so the services raise
|
| 159 |
+
# PatentNotFound only when a backend positively said so, and everything else
|
| 160 |
+
# arrives here as UpstreamUnavailable.
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.exception_handler(PatentNotFound)
|
| 164 |
+
async def _patent_not_found_handler(request: Request, exc: PatentNotFound):
|
| 165 |
+
return JSONResponse({"detail": str(exc)}, status_code=404)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.exception_handler(UpstreamUnavailable)
|
| 169 |
+
async def _upstream_unavailable_handler(request: Request, exc: UpstreamUnavailable):
|
| 170 |
+
return JSONResponse({"detail": str(exc)}, status_code=504 if exc.timeout else 502)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@app.exception_handler(OPSUnconfigured)
|
| 174 |
+
async def _ops_unconfigured_handler(request: Request, exc: OPSUnconfigured):
|
| 175 |
+
return JSONResponse({"detail": str(exc)}, status_code=503)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
# Router for scrapping related endpoints
|
| 179 |
scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"])
|
| 180 |
# Router for SERP-scrapping related endpoints
|
|
|
|
| 185 |
# ===================== Search endpoints =====================
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
@serp_router.post("/search_scholar")
|
| 189 |
+
async def search_google_scholar(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 190 |
"""Queries google scholar for the specified query"""
|
| 191 |
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 192 |
+
return await service.google_scholar(params)
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
@serp_router.post("/search_arxiv")
|
| 196 |
+
async def search_arxiv(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 197 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 198 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 199 |
+
return await service.arxiv(params)
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
@serp_router.post("/search_patents")
|
| 203 |
+
async def search_patents(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 204 |
"""Searches google patents for the specified queries and returns the found documents.
|
| 205 |
|
| 206 |
Falls back to the EPO OPS API for any query Google Patents returns nothing
|
| 207 |
for, when OPS credentials are configured.
|
| 208 |
"""
|
| 209 |
logging.info(f"Searching Google Patents for queries: {params.queries}")
|
| 210 |
+
return await service.patents(params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
|
| 213 |
@serp_router.post("/search_brave")
|
| 214 |
+
async def search_brave(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 215 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 216 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 217 |
+
return await service.brave(params)
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
@serp_router.post("/search_bing")
|
| 221 |
+
async def search_bing(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 222 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 223 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 224 |
+
return await service.bing(params)
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
@serp_router.post("/search_duck")
|
| 228 |
+
async def search_duck(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 229 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 230 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 231 |
+
return await service.duckduckgo(params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
|
| 234 |
@serp_router.post("/search")
|
| 235 |
+
async def search(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 236 |
"""Attempts to search the specified queries using ALL backends"""
|
| 237 |
+
return await service.search(params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
# =========================== Scrapping endpoints ===========================
|
| 240 |
|
| 241 |
|
| 242 |
@scrap_router.get("/scrap_patent/{patent_id}")
|
| 243 |
+
async def scrap_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult:
|
| 244 |
"""Scraps the specified patent from Google Patents.
|
| 245 |
|
| 246 |
Falls back to the EPO OPS API (which covers patents missing from Google
|
| 247 |
Patents) when the scrape fails and OPS credentials are configured.
|
| 248 |
"""
|
| 249 |
+
return await service.scrap(patent_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
class ScrapPatentsRequest(BaseModel):
|
| 253 |
"""Request model for scrapping multiple patents."""
|
| 254 |
patent_ids: list[PatentId] = Field(...,
|
| 255 |
+
min_length=1,
|
| 256 |
+
max_length=MAX_BULK_PATENT_IDS,
|
| 257 |
description="List of patent IDs to scrap")
|
| 258 |
|
| 259 |
|
| 260 |
@scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
|
| 261 |
+
async def scrap_patents(params: ScrapPatentsRequest,
|
| 262 |
+
service: PatentServiceDep) -> PatentScrapBulkResponse:
|
| 263 |
"""Scraps multiple patents from Google Patents."""
|
| 264 |
+
return await service.scrap_bulk(params.patent_ids)
|
|
|
|
| 265 |
|
| 266 |
# =========================== EPO OPS endpoints ===========================
|
| 267 |
|
| 268 |
|
| 269 |
@ops_router.post("/search")
|
| 270 |
+
async def ops_keyword_search(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
|
| 271 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 272 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 273 |
+
return await service.ops_keyword_search(params)
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
@ops_router.get("/scrap_patent/{patent_id}")
|
| 277 |
+
async def ops_get_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult:
|
| 278 |
"""Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
|
| 279 |
+
return await service.ops_scrap(patent_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
@ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
|
| 283 |
+
async def ops_get_patents_bulk(params: ScrapPatentsRequest,
|
| 284 |
+
service: PatentServiceDep) -> OPSBulkResponse:
|
| 285 |
"""Retrieves multiple patents via EPO OPS."""
|
| 286 |
+
return await service.ops_scrap_bulk(params.patent_ids)
|
| 287 |
|
| 288 |
# ===============================================================================
|
| 289 |
|
|
@@ -312,9 +312,20 @@ class OPSBulkResponse(BaseModel):
|
|
| 312 |
failed_ids: list[str]
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
results = await asyncio.gather(
|
| 317 |
-
*[
|
| 318 |
patents = [r for r in results if not isinstance(r, Exception)]
|
| 319 |
failed = [numbers[i] for i, r in enumerate(
|
| 320 |
results) if isinstance(r, Exception)]
|
|
|
|
| 312 |
failed_ids: list[str]
|
| 313 |
|
| 314 |
|
| 315 |
+
# Lower than the Google Patents equivalent because each id here costs three
|
| 316 |
+
# OPS requests (biblio + claims + description) rather than one page fetch.
|
| 317 |
+
BULK_OPS_CONCURRENCY_LIMIT = 5
|
| 318 |
+
|
| 319 |
+
|
| 320 |
async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
|
| 321 |
+
semaphore = asyncio.Semaphore(BULK_OPS_CONCURRENCY_LIMIT)
|
| 322 |
+
|
| 323 |
+
async def scrap_one(number: str):
|
| 324 |
+
async with semaphore:
|
| 325 |
+
return await ops_scrap_patent(client, number)
|
| 326 |
+
|
| 327 |
results = await asyncio.gather(
|
| 328 |
+
*[scrap_one(n) for n in numbers], return_exceptions=True)
|
| 329 |
patents = [r for r in results if not isinstance(r, Exception)]
|
| 330 |
failed = [numbers[i] for i, r in enumerate(
|
| 331 |
results) if isinstance(r, Exception)]
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lint configuration. The rule set is deliberately small: this repo had no
|
| 2 |
+
# linter, and the first pass found a duplicate import shadowing another, four
|
| 3 |
+
# unused imports and an f-string with no placeholders - all of which a
|
| 4 |
+
# default pyflakes-equivalent catches. Widen the selection when the team
|
| 5 |
+
# wants more, rather than starting from a large set and adding ignores.
|
| 6 |
+
[tool.ruff]
|
| 7 |
+
line-length = 120
|
| 8 |
+
|
| 9 |
+
[tool.ruff.lint]
|
| 10 |
+
select = [
|
| 11 |
+
"F", # pyflakes: unused/duplicate imports, f-strings without placeholders
|
| 12 |
+
"E9", # syntax and IO errors
|
| 13 |
+
"B", # bugbear: likely-wrong constructs
|
| 14 |
+
]
|
| 15 |
+
ignore = [
|
| 16 |
+
"B008", # FastAPI's Depends()/Field() in defaults is the documented idiom
|
| 17 |
+
]
|
|
@@ -10,3 +10,5 @@
|
|
| 10 |
pytest>=8,<10
|
| 11 |
pytest-asyncio>=1.4.0,<1.5
|
| 12 |
respx>=0.23.1,<0.24
|
|
|
|
|
|
|
|
|
| 10 |
pytest>=8,<10
|
| 11 |
pytest-asyncio>=1.4.0,<1.5
|
| 12 |
respx>=0.23.1,<0.24
|
| 13 |
+
# Lint. Pinned like everything else; `ruff check .` is what CI runs.
|
| 14 |
+
ruff>=0.14.6,<0.15
|
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import asyncio
|
| 2 |
-
import logging
|
| 3 |
import re
|
| 4 |
from typing import Optional
|
| 5 |
from httpx import AsyncClient
|
|
@@ -131,11 +130,24 @@ class PatentScrapBulkResponse(BaseModel):
|
|
| 131 |
failed_ids: list[str]
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
async def scrap_patent_bulk_async(client: AsyncClient, patent_ids: list[str]) -> PatentScrapBulkResponse:
|
| 135 |
-
"""Scrape multiple patents asynchronously."""
|
| 136 |
urls = [
|
| 137 |
f"https://patents.google.com/patent/{pid}/en" for pid in patent_ids]
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
filtered_results = [
|
| 141 |
res for res in results if not isinstance(res, Exception)]
|
|
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
import re
|
| 3 |
from typing import Optional
|
| 4 |
from httpx import AsyncClient
|
|
|
|
| 130 |
failed_ids: list[str]
|
| 131 |
|
| 132 |
|
| 133 |
+
# Cap how many patent pages are fetched at once. Without this a single
|
| 134 |
+
# accepted request opened one outbound scrape per id, all at the same time;
|
| 135 |
+
# the Playwright scrapers have had an equivalent limit from the start and
|
| 136 |
+
# this brings the HTTP path in line with them.
|
| 137 |
+
BULK_SCRAP_CONCURRENCY_LIMIT = 10
|
| 138 |
+
|
| 139 |
+
|
| 140 |
async def scrap_patent_bulk_async(client: AsyncClient, patent_ids: list[str]) -> PatentScrapBulkResponse:
|
| 141 |
+
"""Scrape multiple patents asynchronously, a bounded number at a time."""
|
| 142 |
urls = [
|
| 143 |
f"https://patents.google.com/patent/{pid}/en" for pid in patent_ids]
|
| 144 |
+
semaphore = asyncio.Semaphore(BULK_SCRAP_CONCURRENCY_LIMIT)
|
| 145 |
+
|
| 146 |
+
async def scrap_one(url: str):
|
| 147 |
+
async with semaphore:
|
| 148 |
+
return await scrap_patent_async(client, url)
|
| 149 |
+
|
| 150 |
+
results = await asyncio.gather(*[scrap_one(url) for url in urls], return_exceptions=True)
|
| 151 |
|
| 152 |
filtered_results = [
|
| 153 |
res for res in results if not isinstance(res, Exception)]
|
|
@@ -3,11 +3,11 @@ from typing import Optional
|
|
| 3 |
from duckduckgo_search import DDGS
|
| 4 |
import httpx
|
| 5 |
from pydantic import BaseModel, Field, field_validator
|
| 6 |
-
from playwright.async_api import Browser, BrowserContext, Page
|
| 7 |
from urllib.parse import quote_plus
|
| 8 |
-
import logging
|
| 9 |
import re
|
| 10 |
from lxml import etree
|
|
|
|
| 11 |
from asyncio import Semaphore
|
| 12 |
|
| 13 |
# Concurrency limit for Playwright browser contexts.
|
|
@@ -15,8 +15,18 @@ from asyncio import Semaphore
|
|
| 15 |
PLAYWRIGHT_CONCURRENCY_LIMIT = 10
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
class SerpQuery(BaseModel):
|
| 19 |
queries: list[str] = Field(...,
|
|
|
|
|
|
|
| 20 |
description="The list of queries to search for")
|
| 21 |
n_results: int = Field(
|
| 22 |
10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
|
|
@@ -50,14 +60,26 @@ class BrowserUnavailableError(Exception):
|
|
| 50 |
super().__init__("Playwright browser is not available (it failed to start at startup).")
|
| 51 |
|
| 52 |
|
| 53 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
"""Raised when DuckDuckGo returns zero results.
|
| 55 |
|
| 56 |
The duckduckgo_search library doesn't always raise when its request is
|
| 57 |
blocked/rate-limited — it can also just parse zero results from the
|
| 58 |
-
anti-bot response page and return an empty list.
|
| 59 |
-
|
| 60 |
-
|
| 61 |
"""
|
| 62 |
|
| 63 |
def __init__(self, *args):
|
|
@@ -78,8 +100,12 @@ async def playwright_open_page(browser: Optional[Browser]):
|
|
| 78 |
try:
|
| 79 |
yield page
|
| 80 |
finally:
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
async def _block_stylesheet_and_image_resources(route, request):
|
|
@@ -113,13 +139,21 @@ async def _extract_google_scholar_results(page: Page, n_results: int) -> list[di
|
|
| 113 |
return results
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
|
| 117 |
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
|
| 118 |
async with playwright_open_page(browser) as page:
|
| 119 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 120 |
|
| 121 |
-
|
| 122 |
-
await page.goto(url)
|
| 123 |
|
| 124 |
return await _extract_google_scholar_results(page, n_results)
|
| 125 |
|
|
@@ -139,7 +173,7 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
|
|
| 139 |
# Wait for at least one search result item to appear
|
| 140 |
# This ensures the page has loaded enough to start scraping
|
| 141 |
await page.wait_for_function(
|
| 142 |
-
|
| 143 |
timeout=30_000
|
| 144 |
)
|
| 145 |
|
|
@@ -169,13 +203,17 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
|
|
| 169 |
return results[:n_results]
|
| 170 |
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
|
| 173 |
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
|
| 174 |
async with playwright_open_page(browser) as page:
|
| 175 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 176 |
|
| 177 |
-
|
| 178 |
-
await page.goto(url)
|
| 179 |
|
| 180 |
return await _extract_google_patents_results(page, n_results)
|
| 181 |
|
|
@@ -217,13 +255,22 @@ async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
|
|
| 217 |
return results
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
|
| 221 |
"""Queries Brave Search for the specified query."""
|
| 222 |
async with playwright_open_page(browser) as page:
|
| 223 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 224 |
|
| 225 |
-
|
| 226 |
-
await page.goto(url)
|
| 227 |
|
| 228 |
return await _extract_brave_results(page, n_results)
|
| 229 |
|
|
@@ -266,25 +313,50 @@ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
|
|
| 266 |
return results
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 270 |
"""Queries bing search for the specified query"""
|
| 271 |
async with playwright_open_page(browser) as page:
|
| 272 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 273 |
|
| 274 |
-
|
| 275 |
-
await page.goto(url)
|
| 276 |
|
| 277 |
return await _extract_bing_results(page, n_results)
|
| 278 |
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
async def query_ddg_search(q: str, n_results: int = 10):
|
| 281 |
-
"""Queries duckduckgo search for the specified query
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
| 288 |
|
| 289 |
if not results:
|
| 290 |
raise DuckDuckGoBlockedException()
|
|
|
|
| 3 |
from duckduckgo_search import DDGS
|
| 4 |
import httpx
|
| 5 |
from pydantic import BaseModel, Field, field_validator
|
| 6 |
+
from playwright.async_api import Browser, BrowserContext, Page
|
| 7 |
from urllib.parse import quote_plus
|
|
|
|
| 8 |
import re
|
| 9 |
from lxml import etree
|
| 10 |
+
import asyncio
|
| 11 |
from asyncio import Semaphore
|
| 12 |
|
| 13 |
# Concurrency limit for Playwright browser contexts.
|
|
|
|
| 15 |
PLAYWRIGHT_CONCURRENCY_LIMIT = 10
|
| 16 |
|
| 17 |
|
| 18 |
+
# One request must not be able to fan out without limit. Every search
|
| 19 |
+
# endpoint issues one outbound call per query (and `/serp/search` may try
|
| 20 |
+
# three backends for each), so the length of this list is a direct
|
| 21 |
+
# multiplier on outbound load - `n_results` was clamped from the start
|
| 22 |
+
# while the list holding the queries was not bounded at all.
|
| 23 |
+
MAX_QUERIES_PER_REQUEST = 50
|
| 24 |
+
|
| 25 |
+
|
| 26 |
class SerpQuery(BaseModel):
|
| 27 |
queries: list[str] = Field(...,
|
| 28 |
+
min_length=1,
|
| 29 |
+
max_length=MAX_QUERIES_PER_REQUEST,
|
| 30 |
description="The list of queries to search for")
|
| 31 |
n_results: int = Field(
|
| 32 |
10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
|
|
|
|
| 60 |
super().__init__("Playwright browser is not available (it failed to start at startup).")
|
| 61 |
|
| 62 |
|
| 63 |
+
class EmptyResultsError(Exception):
|
| 64 |
+
"""A backend returned zero results.
|
| 65 |
+
|
| 66 |
+
Deliberately distinct from a backend erroring, because zero results is
|
| 67 |
+
ambiguous: it can mean the query genuinely has no hits, or that the
|
| 68 |
+
backend served an anti-bot page that parsed to nothing. Callers (the
|
| 69 |
+
`/serp/search` fallback chain in particular) rely on an exception to
|
| 70 |
+
move on to the next backend, but should not treat this one as evidence
|
| 71 |
+
that the backend itself is unhealthy.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class DuckDuckGoBlockedException(EmptyResultsError):
|
| 76 |
"""Raised when DuckDuckGo returns zero results.
|
| 77 |
|
| 78 |
The duckduckgo_search library doesn't always raise when its request is
|
| 79 |
blocked/rate-limited — it can also just parse zero results from the
|
| 80 |
+
anti-bot response page and return an empty list. A hard block usually
|
| 81 |
+
does raise (and that still counts against the backend); this covers the
|
| 82 |
+
soft case, which is indistinguishable from a genuinely empty search.
|
| 83 |
"""
|
| 84 |
|
| 85 |
def __init__(self, *args):
|
|
|
|
| 100 |
try:
|
| 101 |
yield page
|
| 102 |
finally:
|
| 103 |
+
# Close the context even if closing the page fails (a crashed
|
| 104 |
+
# renderer), otherwise the context leaks for the process's life.
|
| 105 |
+
try:
|
| 106 |
+
await page.close()
|
| 107 |
+
finally:
|
| 108 |
+
await context.close()
|
| 109 |
|
| 110 |
|
| 111 |
async def _block_stylesheet_and_image_resources(route, request):
|
|
|
|
| 139 |
return results
|
| 140 |
|
| 141 |
|
| 142 |
+
def google_scholar_url(q: str, n_results: int) -> str:
|
| 143 |
+
"""The Google Scholar results URL for a query.
|
| 144 |
+
|
| 145 |
+
Split out from the scraper so it can be asserted on directly: the
|
| 146 |
+
Playwright tests replace `page.goto` and never see the URL.
|
| 147 |
+
"""
|
| 148 |
+
return f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
|
| 149 |
+
|
| 150 |
+
|
| 151 |
async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
|
| 152 |
"""Queries google scholar for the specified query and number of results. Returns relevant papers"""
|
| 153 |
async with playwright_open_page(browser) as page:
|
| 154 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 155 |
|
| 156 |
+
await page.goto(google_scholar_url(q, n_results))
|
|
|
|
| 157 |
|
| 158 |
return await _extract_google_scholar_results(page, n_results)
|
| 159 |
|
|
|
|
| 173 |
# Wait for at least one search result item to appear
|
| 174 |
# This ensures the page has loaded enough to start scraping
|
| 175 |
await page.wait_for_function(
|
| 176 |
+
"() => document.querySelectorAll('search-result-item').length >= 1",
|
| 177 |
timeout=30_000
|
| 178 |
)
|
| 179 |
|
|
|
|
| 203 |
return results[:n_results]
|
| 204 |
|
| 205 |
|
| 206 |
+
def google_patents_search_url(q: str, n_results: int) -> str:
|
| 207 |
+
"""The Google Patents search URL for a query."""
|
| 208 |
+
return f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
|
| 212 |
"""Queries google patents for the specified query and number of results. Returns relevant patents"""
|
| 213 |
async with playwright_open_page(browser) as page:
|
| 214 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 215 |
|
| 216 |
+
await page.goto(google_patents_search_url(q, n_results))
|
|
|
|
| 217 |
|
| 218 |
return await _extract_google_patents_results(page, n_results)
|
| 219 |
|
|
|
|
| 255 |
return results
|
| 256 |
|
| 257 |
|
| 258 |
+
def brave_search_url(q: str, n_results: int = 10) -> str:
|
| 259 |
+
"""The Brave Search results URL for a query.
|
| 260 |
+
|
| 261 |
+
Brave paginates rather than taking a result count, so `n_results` is
|
| 262 |
+
applied while extracting rather than in the URL; it is accepted here to
|
| 263 |
+
keep the builders interchangeable.
|
| 264 |
+
"""
|
| 265 |
+
return f"https://search.brave.com/search?q={quote_plus(q)}"
|
| 266 |
+
|
| 267 |
+
|
| 268 |
async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
|
| 269 |
"""Queries Brave Search for the specified query."""
|
| 270 |
async with playwright_open_page(browser) as page:
|
| 271 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 272 |
|
| 273 |
+
await page.goto(brave_search_url(q, n_results))
|
|
|
|
| 274 |
|
| 275 |
return await _extract_brave_results(page, n_results)
|
| 276 |
|
|
|
|
| 313 |
return results
|
| 314 |
|
| 315 |
|
| 316 |
+
def bing_search_url(q: str, n_results: int = 10) -> str:
|
| 317 |
+
"""The Bing results URL for a query.
|
| 318 |
+
|
| 319 |
+
Bing paginates rather than taking a result count, so `n_results` is
|
| 320 |
+
applied while extracting rather than in the URL; it is accepted here to
|
| 321 |
+
keep the builders interchangeable.
|
| 322 |
+
"""
|
| 323 |
+
return f"https://www.bing.com/search?q={quote_plus(q)}"
|
| 324 |
+
|
| 325 |
+
|
| 326 |
async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
|
| 327 |
"""Queries bing search for the specified query"""
|
| 328 |
async with playwright_open_page(browser) as page:
|
| 329 |
await page.route("**/*", _block_stylesheet_and_image_resources)
|
| 330 |
|
| 331 |
+
await page.goto(bing_search_url(q, n_results))
|
|
|
|
| 332 |
|
| 333 |
return await _extract_bing_results(page, n_results)
|
| 334 |
|
| 335 |
|
| 336 |
+
def _ddg_text_blocking(q: str, n_results: int) -> list[dict]:
|
| 337 |
+
"""The synchronous half of a DuckDuckGo query.
|
| 338 |
+
|
| 339 |
+
`DDGS.text()` performs blocking HTTP I/O. It is deliberately isolated
|
| 340 |
+
here so `query_ddg_search` can hand it to a worker thread rather than
|
| 341 |
+
calling it on the event loop.
|
| 342 |
+
"""
|
| 343 |
+
return list(DDGS().text(q, max_results=n_results))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
async def query_ddg_search(q: str, n_results: int = 10):
|
| 347 |
+
"""Queries duckduckgo search for the specified query.
|
| 348 |
+
|
| 349 |
+
The underlying library call is synchronous, so it runs in a worker
|
| 350 |
+
thread: calling it directly would freeze the whole event loop for the
|
| 351 |
+
duration of the request, and DuckDuckGo is the first backend tried for
|
| 352 |
+
every query in `/serp/search`.
|
| 353 |
+
"""
|
| 354 |
+
raw_results = await asyncio.to_thread(_ddg_text_blocking, q, n_results)
|
| 355 |
|
| 356 |
+
results = [
|
| 357 |
+
{"title": r["title"], "body": r["body"], "href": r["href"]}
|
| 358 |
+
for r in raw_results
|
| 359 |
+
]
|
| 360 |
|
| 361 |
if not results:
|
| 362 |
raise DuckDuckGoBlockedException()
|
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend orchestration, separated from HTTP wiring.
|
| 2 |
+
|
| 3 |
+
The policy in here - which backend to try, in what order, when to fall back
|
| 4 |
+
to another, what counts as evidence a backend is unhealthy, and when a
|
| 5 |
+
patent is genuinely absent rather than merely unreachable - is domain
|
| 6 |
+
policy. It would be equally true behind a CLI or a queue consumer, and it
|
| 7 |
+
was previously written inline in FastAPI route handlers, which meant
|
| 8 |
+
exercising any of it cost an ASGI round-trip.
|
| 9 |
+
|
| 10 |
+
The stateful collaborators (HTTP client, browser, circuit breaker, OPS
|
| 11 |
+
credentials) are constructor arguments rather than module globals reached
|
| 12 |
+
into at call time. That is what lets a test build a service with its own
|
| 13 |
+
circuit breaker instead of resetting a process-wide singleton between
|
| 14 |
+
tests, and hand in a stand-in for the OPS credentials instead of assigning
|
| 15 |
+
to private attributes on the real token manager.
|
| 16 |
+
|
| 17 |
+
The stateless backend functions (`query_arxiv` and friends) stay
|
| 18 |
+
module-level imports: they hold no state, so a test that wants to replace
|
| 19 |
+
one can monkeypatch it here without any of the problems shared mutable
|
| 20 |
+
state causes.
|
| 21 |
+
|
| 22 |
+
Services raise domain errors - `PatentNotFound`, `UpstreamUnavailable` -
|
| 23 |
+
rather than `HTTPException`. Mapping those onto status codes is the HTTP
|
| 24 |
+
layer's job, and lives in app.py.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import asyncio
|
| 28 |
+
import logging
|
| 29 |
+
from typing import Callable, Optional
|
| 30 |
+
|
| 31 |
+
import httpx
|
| 32 |
+
from httpx import AsyncClient, HTTPStatusError
|
| 33 |
+
from playwright.async_api import Browser
|
| 34 |
+
|
| 35 |
+
from circuit_breaker import CircuitBreaker, CircuitOpenError
|
| 36 |
+
from ops import (OPSBulkResponse, ops_scrap_patent, ops_scrap_patent_bulk,
|
| 37 |
+
ops_search)
|
| 38 |
+
from scrap import (PatentScrapBulkResponse, PatentScrapResult,
|
| 39 |
+
scrap_patent_async, scrap_patent_bulk_async)
|
| 40 |
+
from serp import (EmptyResultsError, SerpQuery, SerpResults, query_arxiv,
|
| 41 |
+
query_bing_search, query_brave_search, query_ddg_search,
|
| 42 |
+
query_google_patents, query_google_scholar)
|
| 43 |
+
from utils import log_gathered_exceptions
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ================================ domain errors ================================
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class PatentNotFound(Exception):
|
| 52 |
+
"""Every backend consulted positively said the document is absent.
|
| 53 |
+
|
| 54 |
+
Distinct from UpstreamUnavailable on purpose: MCP_INSTRUCTIONS tells an
|
| 55 |
+
agent that a not-found patent is genuinely absent everywhere and to
|
| 56 |
+
move on without retrying, so this must never stand in for "we couldn't
|
| 57 |
+
reach anyone".
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class UpstreamUnavailable(Exception):
|
| 62 |
+
"""A backend failed to answer. Says nothing about whether the document
|
| 63 |
+
exists."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, message: str, *, timeout: bool = False):
|
| 66 |
+
super().__init__(message)
|
| 67 |
+
self.timeout = timeout
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class OPSUnconfigured(Exception):
|
| 71 |
+
"""An OPS-only operation was requested without credentials configured."""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _is_not_found(exc: BaseException) -> bool:
|
| 75 |
+
"""True only when a backend positively said the document is absent."""
|
| 76 |
+
return isinstance(exc, HTTPStatusError) and exc.response.status_code == 404
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _as_upstream(exc: BaseException, message: str) -> UpstreamUnavailable:
|
| 80 |
+
return UpstreamUnavailable(message, timeout=isinstance(exc, httpx.TimeoutException))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ================================ result shaping ================================
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def shape_serp_results(results: list) -> SerpResults:
|
| 87 |
+
"""Flatten a list of per-query result-lists (or Exceptions, from a
|
| 88 |
+
`return_exceptions=True` gather) into one SerpResults, surfacing the
|
| 89 |
+
last error only when every query failed.
|
| 90 |
+
"""
|
| 91 |
+
if not results:
|
| 92 |
+
# The request models bound the query list, so this is unreachable
|
| 93 |
+
# from the endpoints; keep the helper total anyway rather than
|
| 94 |
+
# indexing into an empty list, since every search path shares it.
|
| 95 |
+
return SerpResults(results=[], error="No queries were provided.")
|
| 96 |
+
|
| 97 |
+
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 98 |
+
flattened_results = [
|
| 99 |
+
item for sublist in filtered_results for item in sublist]
|
| 100 |
+
|
| 101 |
+
if len(filtered_results) == 0:
|
| 102 |
+
return SerpResults(results=[], error=str(results[-1]))
|
| 103 |
+
|
| 104 |
+
return SerpResults(results=flattened_results, error=None)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ================================= search service =================================
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class SearchService:
|
| 111 |
+
"""Runs SERP queries across the available backends."""
|
| 112 |
+
|
| 113 |
+
def __init__(self, *, http_client: AsyncClient,
|
| 114 |
+
browser_provider: Callable[[], Optional[Browser]],
|
| 115 |
+
circuit_breaker: CircuitBreaker, ops_tokens):
|
| 116 |
+
self._http = http_client
|
| 117 |
+
# A provider rather than the browser itself: the browser is started
|
| 118 |
+
# by the app's lifespan, after this service may already exist, and
|
| 119 |
+
# is None when Playwright failed to start.
|
| 120 |
+
self._browser_provider = browser_provider
|
| 121 |
+
self._breaker = circuit_breaker
|
| 122 |
+
self._ops_tokens = ops_tokens
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def browser(self) -> Optional[Browser]:
|
| 126 |
+
return self._browser_provider()
|
| 127 |
+
|
| 128 |
+
async def _run_queries(self, fn, params: SerpQuery, context: str) -> SerpResults:
|
| 129 |
+
"""Run `fn(query, n_results)` concurrently for every query, log any
|
| 130 |
+
failures, and shape the result the way every simple search path
|
| 131 |
+
expects.
|
| 132 |
+
"""
|
| 133 |
+
results = await asyncio.gather(
|
| 134 |
+
*[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
|
| 135 |
+
log_gathered_exceptions(results, context, params.queries)
|
| 136 |
+
return shape_serp_results(results)
|
| 137 |
+
|
| 138 |
+
# ------------------------------ single backend ------------------------------
|
| 139 |
+
|
| 140 |
+
async def google_scholar(self, params: SerpQuery) -> SerpResults:
|
| 141 |
+
return await self._run_queries(
|
| 142 |
+
lambda q, n: query_google_scholar(self.browser, q, n), params,
|
| 143 |
+
"google scholar search")
|
| 144 |
+
|
| 145 |
+
async def arxiv(self, params: SerpQuery) -> SerpResults:
|
| 146 |
+
return await self._run_queries(
|
| 147 |
+
lambda q, n: query_arxiv(self._http, q, n), params, "arxiv search")
|
| 148 |
+
|
| 149 |
+
async def brave(self, params: SerpQuery) -> SerpResults:
|
| 150 |
+
return await self._run_queries(
|
| 151 |
+
lambda q, n: query_brave_search(self.browser, q, n), params, "brave search")
|
| 152 |
+
|
| 153 |
+
async def bing(self, params: SerpQuery) -> SerpResults:
|
| 154 |
+
return await self._run_queries(
|
| 155 |
+
lambda q, n: query_bing_search(self.browser, q, n), params, "bing search")
|
| 156 |
+
|
| 157 |
+
async def duckduckgo(self, params: SerpQuery) -> SerpResults:
|
| 158 |
+
return await self._run_queries(
|
| 159 |
+
lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
|
| 160 |
+
|
| 161 |
+
async def ops_keyword_search(self, params: SerpQuery) -> SerpResults:
|
| 162 |
+
return await self._run_queries(
|
| 163 |
+
lambda q, n: ops_search(self._http, q, n), params, "OPS search")
|
| 164 |
+
|
| 165 |
+
# -------------------------------- patents --------------------------------
|
| 166 |
+
|
| 167 |
+
async def patents(self, params: SerpQuery) -> SerpResults:
|
| 168 |
+
"""Google Patents, falling back to EPO OPS for any query it returns
|
| 169 |
+
nothing for (when OPS credentials are configured)."""
|
| 170 |
+
results = await asyncio.gather(
|
| 171 |
+
*[query_google_patents(self.browser, q, params.n_results)
|
| 172 |
+
for q in params.queries],
|
| 173 |
+
return_exceptions=True)
|
| 174 |
+
log_gathered_exceptions(results, "google patent search", params.queries)
|
| 175 |
+
|
| 176 |
+
# Gathered rather than awaited one at a time: this path exists for
|
| 177 |
+
# when Google Patents is unavailable, so it is exactly when a
|
| 178 |
+
# request can least afford N sequential round-trips to the slower
|
| 179 |
+
# backend.
|
| 180 |
+
if self._ops_tokens.configured:
|
| 181 |
+
needs_fallback = [
|
| 182 |
+
i for i, res in enumerate(results)
|
| 183 |
+
if isinstance(res, Exception) or not res]
|
| 184 |
+
if needs_fallback:
|
| 185 |
+
logger.info(
|
| 186 |
+
f"Google Patents empty for {len(needs_fallback)} quer(y/ies), trying OPS.")
|
| 187 |
+
fallback = await asyncio.gather(
|
| 188 |
+
*[ops_search(self._http, params.queries[i], params.n_results)
|
| 189 |
+
for i in needs_fallback],
|
| 190 |
+
return_exceptions=True)
|
| 191 |
+
# strict: gather returns exactly one result per index, so a
|
| 192 |
+
# length mismatch here would be a bug worth surfacing.
|
| 193 |
+
for i, res in zip(needs_fallback, fallback, strict=True):
|
| 194 |
+
if isinstance(res, Exception):
|
| 195 |
+
logger.warning(f"OPS fallback failed for `{params.queries[i]}`: {res}")
|
| 196 |
+
else:
|
| 197 |
+
results[i] = res
|
| 198 |
+
|
| 199 |
+
return shape_serp_results(results)
|
| 200 |
+
|
| 201 |
+
# ----------------------------- all backends -----------------------------
|
| 202 |
+
|
| 203 |
+
async def _search_one(self, q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
| 204 |
+
"""Try DDG, then Brave, then Bing for a single query; stop at the
|
| 205 |
+
first success."""
|
| 206 |
+
backends = [
|
| 207 |
+
("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
|
| 208 |
+
("Brave Search", lambda: query_brave_search(self.browser, q, n_results)),
|
| 209 |
+
("Bing", lambda: query_bing_search(self.browser, q, n_results)),
|
| 210 |
+
]
|
| 211 |
+
last_error: Optional[Exception] = None
|
| 212 |
+
for name, call in backends:
|
| 213 |
+
try:
|
| 214 |
+
self._breaker.before_call(name)
|
| 215 |
+
except CircuitOpenError as e:
|
| 216 |
+
logger.info(f"Skipping {name} for query `{q}`: {e}")
|
| 217 |
+
last_error = e
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
logger.info(f"Querying {name} with query: `{q}`")
|
| 222 |
+
result = await call()
|
| 223 |
+
self._breaker.record_success(name)
|
| 224 |
+
return q, result, None
|
| 225 |
+
except EmptyResultsError as e:
|
| 226 |
+
# Zero results is ambiguous - no hits, or a soft block that
|
| 227 |
+
# parsed to nothing. Move on to the next backend, but don't
|
| 228 |
+
# hold it against this one: the breaker is shared across
|
| 229 |
+
# every request, so counting it would let a few
|
| 230 |
+
# unusual-but-legitimate queries disable the primary backend
|
| 231 |
+
# for everyone. A hard block normally raises a real error,
|
| 232 |
+
# which is handled below.
|
| 233 |
+
logger.info(f"{name} returned no results for query `{q}`")
|
| 234 |
+
last_error = e
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Failed to query {name} with query `{q}`: {e}")
|
| 237 |
+
self._breaker.record_failure(name)
|
| 238 |
+
last_error = e
|
| 239 |
+
|
| 240 |
+
return q, [], f"All backends failed for query '{q}': {last_error}"
|
| 241 |
+
|
| 242 |
+
async def search(self, params: SerpQuery) -> SerpResults:
|
| 243 |
+
"""Search every query across the full backend fallback chain."""
|
| 244 |
+
outcomes = await asyncio.gather(
|
| 245 |
+
*[self._search_one(q, params.n_results) for q in params.queries])
|
| 246 |
+
|
| 247 |
+
results: list[dict] = []
|
| 248 |
+
errors: list[str] = []
|
| 249 |
+
for _q, res, err in outcomes:
|
| 250 |
+
results.extend(res)
|
| 251 |
+
if err:
|
| 252 |
+
errors.append(err)
|
| 253 |
+
|
| 254 |
+
if len(results) == 0:
|
| 255 |
+
return SerpResults(
|
| 256 |
+
results=[],
|
| 257 |
+
error="; ".join(errors) if errors else "All backends are rate-limited.")
|
| 258 |
+
|
| 259 |
+
return SerpResults(results=results, error="; ".join(errors) if errors else None)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ================================= patent service =================================
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class PatentService:
|
| 266 |
+
"""Retrieves patent full text, from Google Patents or EPO OPS."""
|
| 267 |
+
|
| 268 |
+
def __init__(self, *, http_client: AsyncClient, ops_tokens):
|
| 269 |
+
self._http = http_client
|
| 270 |
+
self._ops_tokens = ops_tokens
|
| 271 |
+
|
| 272 |
+
async def scrap(self, patent_id: str) -> PatentScrapResult:
|
| 273 |
+
"""Scrape from Google Patents, falling back to EPO OPS.
|
| 274 |
+
|
| 275 |
+
Raises PatentNotFound only when every backend consulted said the
|
| 276 |
+
document is absent; anything else raises UpstreamUnavailable.
|
| 277 |
+
"""
|
| 278 |
+
google_error: BaseException
|
| 279 |
+
try:
|
| 280 |
+
return await scrap_patent_async(
|
| 281 |
+
self._http, f"https://patents.google.com/patent/{patent_id}/en")
|
| 282 |
+
except HTTPStatusError as e:
|
| 283 |
+
google_error = e
|
| 284 |
+
logger.warning(
|
| 285 |
+
f"Google Patents returned {e.response.status_code} for {patent_id}.")
|
| 286 |
+
except Exception as e:
|
| 287 |
+
google_error = e
|
| 288 |
+
logger.warning(f"Failed to scrap patent {patent_id}: {e}")
|
| 289 |
+
|
| 290 |
+
if not self._ops_tokens.configured:
|
| 291 |
+
if _is_not_found(google_error):
|
| 292 |
+
raise PatentNotFound(
|
| 293 |
+
f"Patent '{patent_id}' not found on Google Patents "
|
| 294 |
+
"(EPO OPS fallback not configured).")
|
| 295 |
+
raise _as_upstream(
|
| 296 |
+
google_error,
|
| 297 |
+
f"Google Patents request failed for '{patent_id}' and the EPO OPS "
|
| 298 |
+
f"fallback is not configured: {google_error}")
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
logger.info(f"Trying OPS for patent {patent_id}.")
|
| 302 |
+
return await ops_scrap_patent(self._http, patent_id)
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.warning(f"OPS fallback failed for {patent_id}: {e}")
|
| 305 |
+
# Only claim the patent doesn't exist when *both* backends said so.
|
| 306 |
+
if _is_not_found(google_error) and _is_not_found(e):
|
| 307 |
+
raise PatentNotFound(
|
| 308 |
+
f"Patent '{patent_id}' not found on Google Patents or EPO OPS.") from e
|
| 309 |
+
if isinstance(e, HTTPStatusError):
|
| 310 |
+
raise _as_upstream(
|
| 311 |
+
e, f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
|
| 312 |
+
raise _as_upstream(e, f"EPO OPS request failed for '{patent_id}': {e}") from e
|
| 313 |
+
|
| 314 |
+
async def scrap_bulk(self, patent_ids: list[str]) -> PatentScrapBulkResponse:
|
| 315 |
+
return await scrap_patent_bulk_async(self._http, patent_ids)
|
| 316 |
+
|
| 317 |
+
async def ops_scrap(self, patent_id: str) -> PatentScrapResult:
|
| 318 |
+
"""Retrieve via EPO OPS only, with no Google Patents fallback."""
|
| 319 |
+
if not self._ops_tokens.configured:
|
| 320 |
+
raise OPSUnconfigured(
|
| 321 |
+
"EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
|
| 322 |
+
try:
|
| 323 |
+
return await ops_scrap_patent(self._http, patent_id)
|
| 324 |
+
except HTTPStatusError as e:
|
| 325 |
+
if e.response.status_code == 404:
|
| 326 |
+
# OPS is the only backend asked here, so its 404 is the
|
| 327 |
+
# whole answer.
|
| 328 |
+
raise PatentNotFound(
|
| 329 |
+
f"Patent '{patent_id}' not found in EPO OPS.") from e
|
| 330 |
+
raise _as_upstream(
|
| 331 |
+
e, f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
|
| 332 |
+
except Exception as e:
|
| 333 |
+
logger.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
|
| 334 |
+
raise _as_upstream(e, f"EPO OPS request failed for '{patent_id}': {e}") from e
|
| 335 |
+
|
| 336 |
+
async def ops_scrap_bulk(self, patent_ids: list[str]) -> OPSBulkResponse:
|
| 337 |
+
return await ops_scrap_patent_bulk(self._http, patent_ids)
|
|
@@ -59,3 +59,29 @@ async def browser():
|
|
| 59 |
yield b
|
| 60 |
await b.close()
|
| 61 |
await pw.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
yield b
|
| 60 |
await b.close()
|
| 61 |
await pw.stop()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest_asyncio.fixture
|
| 65 |
+
async def page_factory(browser):
|
| 66 |
+
"""Returns a callable producing a real Page with fixture HTML loaded.
|
| 67 |
+
|
| 68 |
+
This is the whole seam the Playwright-driven tests need now that every
|
| 69 |
+
scraper is split into a URL builder, a navigation step and an
|
| 70 |
+
`_extract_*` function: the extractors take an already-loaded page, so
|
| 71 |
+
`set_content` reaches them directly. It replaces the goto-patching
|
| 72 |
+
Browser wrapper that used to be required, which had to accept the
|
| 73 |
+
navigation URL and throw it away - and so hid every URL bug.
|
| 74 |
+
"""
|
| 75 |
+
contexts = []
|
| 76 |
+
|
| 77 |
+
async def make(html: str):
|
| 78 |
+
context = await browser.new_context()
|
| 79 |
+
contexts.append(context)
|
| 80 |
+
page = await context.new_page()
|
| 81 |
+
await page.set_content(html)
|
| 82 |
+
return page
|
| 83 |
+
|
| 84 |
+
yield make
|
| 85 |
+
|
| 86 |
+
for context in contexts:
|
| 87 |
+
await context.close()
|
|
@@ -1,7 +1,4 @@
|
|
| 1 |
-
"""Shared test helpers: fixture loading
|
| 2 |
-
Browser wrapper used to characterize the scraper functions in `serp.py`
|
| 3 |
-
without hitting live search engines.
|
| 4 |
-
"""
|
| 5 |
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
@@ -11,63 +8,3 @@ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
|
| 11 |
def load_fixture(*parts: str) -> str:
|
| 12 |
"""Read a text fixture from tests/fixtures/<parts...>."""
|
| 13 |
return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class _GotoPatchingContext:
|
| 17 |
-
"""Wraps a real Playwright BrowserContext so every Page it creates loads
|
| 18 |
-
fixture HTML via `set_content()` instead of navigating over the network.
|
| 19 |
-
"""
|
| 20 |
-
|
| 21 |
-
def __init__(self, real_context, html: str):
|
| 22 |
-
self._real_context = real_context
|
| 23 |
-
self._html = html
|
| 24 |
-
|
| 25 |
-
async def new_page(self):
|
| 26 |
-
page = await self._real_context.new_page()
|
| 27 |
-
html = self._html
|
| 28 |
-
|
| 29 |
-
async def fake_goto(url, **kwargs):
|
| 30 |
-
# The scraper functions don't use the response object `goto`
|
| 31 |
-
# normally returns, so a plain None is a safe stand-in.
|
| 32 |
-
await page.set_content(html)
|
| 33 |
-
return None
|
| 34 |
-
|
| 35 |
-
page.goto = fake_goto
|
| 36 |
-
return page
|
| 37 |
-
|
| 38 |
-
async def close(self):
|
| 39 |
-
await self._real_context.close()
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class _GotoPatchingBrowser:
|
| 43 |
-
"""Wraps a real Playwright Browser the same way, so it can be passed
|
| 44 |
-
directly to `serp.py` functions in place of the real `pw_browser`.
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
def __init__(self, real_browser, html: str):
|
| 48 |
-
self._real_browser = real_browser
|
| 49 |
-
self._html = html
|
| 50 |
-
|
| 51 |
-
async def new_context(self, **kwargs):
|
| 52 |
-
real_context = await self._real_browser.new_context(**kwargs)
|
| 53 |
-
return _GotoPatchingContext(real_context, self._html)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
|
| 57 |
-
"""Build a Browser-like object for characterizing `serp.py`'s scraper
|
| 58 |
-
functions against fixture HTML.
|
| 59 |
-
|
| 60 |
-
Why this exists: each scraper (e.g. `query_bing_search`) registers its
|
| 61 |
-
own catch-all `page.route("**/*", ...)` resource blocker *inside* the
|
| 62 |
-
function, and that handler calls `route.continue_()` (not
|
| 63 |
-
`route.fallback()`) for the main document request. Playwright invokes
|
| 64 |
-
the most-recently-registered matching route first, so any route we
|
| 65 |
-
register on the page *before* calling the function never runs -
|
| 66 |
-
verified empirically, it's not a documented guarantee either way.
|
| 67 |
-
Patching `page.goto` itself, on a page created through this wrapper, is
|
| 68 |
-
the only reliable seam into these functions without touching their
|
| 69 |
-
source (see the code review this test suite followed from: the
|
| 70 |
-
fetch/navigate and parse logic aren't separated, which is exactly what
|
| 71 |
-
makes this workaround necessary).
|
| 72 |
-
"""
|
| 73 |
-
return _GotoPatchingBrowser(real_browser, html)
|
|
|
|
| 1 |
+
"""Shared test helpers: fixture loading."""
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
| 8 |
def load_fixture(*parts: str) -> str:
|
| 9 |
"""Read a text fixture from tests/fixtures/<parts...>."""
|
| 10 |
return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,12 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
doesn't run it, and these tests don't need it since pw_browser is never
|
| 9 |
-
actually used once the query functions are patched out.
|
| 10 |
"""
|
| 11 |
|
| 12 |
import httpx
|
|
@@ -14,9 +12,8 @@ import pytest
|
|
| 14 |
from httpx import ASGITransport
|
| 15 |
|
| 16 |
import app as app_module
|
| 17 |
-
from
|
| 18 |
-
from
|
| 19 |
-
from serp import SerpQuery
|
| 20 |
|
| 21 |
|
| 22 |
@pytest.fixture
|
|
@@ -26,151 +23,134 @@ async def client():
|
|
| 26 |
yield c
|
| 27 |
|
| 28 |
|
| 29 |
-
@pytest.fixture
|
| 30 |
-
def
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# ------------------------- _shape_serp_results / _run_serp_queries -------------------------
|
| 40 |
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
def test_shape_serp_results_flattens_successful_lists():
|
| 43 |
-
result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
|
| 44 |
-
assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
|
| 45 |
-
assert result.error is None
|
| 46 |
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
|
| 50 |
-
assert result.results == []
|
| 51 |
-
assert "second" in result.error
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
def
|
| 55 |
-
|
| 56 |
-
assert result.results == [{"a": 1}]
|
| 57 |
-
assert result.error is None
|
| 58 |
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
|
| 64 |
-
async def
|
| 65 |
-
calls.append((
|
| 66 |
-
return
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
|
|
|
| 72 |
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
# ------------------------------------- endpoints -------------------------------------
|
| 75 |
|
|
|
|
| 76 |
|
| 77 |
-
async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
|
| 78 |
-
async def fake_query_arxiv(client_arg, q, n):
|
| 79 |
-
return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
resp = await client.post(
|
| 84 |
|
| 85 |
assert resp.status_code == 200
|
| 86 |
-
|
| 87 |
-
assert
|
| 88 |
-
assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
|
| 89 |
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
|
| 98 |
|
| 99 |
assert resp.status_code == 200
|
| 100 |
-
|
| 101 |
-
assert data["results"] == []
|
| 102 |
-
assert "boom for a" in data["error"]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
|
| 106 |
-
async def fake_google_patents(browser, q, n):
|
| 107 |
-
return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 108 |
-
|
| 109 |
-
ops_search_called = False
|
| 110 |
-
|
| 111 |
-
async def fake_ops_search(client_arg, q, n):
|
| 112 |
-
nonlocal ops_search_called
|
| 113 |
-
ops_search_called = True
|
| 114 |
-
return []
|
| 115 |
-
|
| 116 |
-
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 117 |
-
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 118 |
-
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 119 |
-
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 120 |
-
|
| 121 |
-
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 122 |
-
|
| 123 |
-
data = resp.json()
|
| 124 |
-
assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 125 |
-
assert ops_search_called is False
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
|
| 129 |
-
async def fake_google_patents(browser, q, n):
|
| 130 |
-
return []
|
| 131 |
|
| 132 |
-
async def fake_ops_search(client_arg, q, n):
|
| 133 |
-
return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
resp = await client.post(
|
| 141 |
|
| 142 |
-
|
| 143 |
-
assert
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
|
| 147 |
-
async def fake_google_patents(browser, q, n):
|
| 148 |
-
return []
|
| 149 |
-
|
| 150 |
-
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 151 |
-
monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
|
| 152 |
-
monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
|
| 153 |
-
|
| 154 |
-
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 155 |
-
|
| 156 |
-
data = resp.json()
|
| 157 |
-
assert data["results"] == []
|
| 158 |
-
assert data["error"] is None
|
| 159 |
|
| 160 |
|
| 161 |
# ------------------------------- patent_id validation -------------------------------
|
| 162 |
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
assert resp.status_code == 422
|
| 167 |
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
|
| 175 |
resp = await client.get("/scrap/scrap_patent/US11930446B2")
|
| 176 |
|
|
@@ -178,38 +158,47 @@ async def test_scrap_patent_accepts_well_formed_patent_id(client, monkeypatch):
|
|
| 178 |
assert resp.json()["title"] == "Widget apparatus"
|
| 179 |
|
| 180 |
|
| 181 |
-
|
| 182 |
-
resp = await client.get("/ops/scrap_patent/not-a-patent-id")
|
| 183 |
-
assert resp.status_code == 422
|
| 184 |
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
async def test_ops_scrap_patents_bulk_rejects_malformed_id_in_list(client):
|
| 193 |
-
resp = await client.post("/ops/scrap_patents_bulk", json={"patent_ids": ["garbage!!"]})
|
| 194 |
-
assert resp.status_code == 422
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
async def fake_ops_search(client_arg, q, n):
|
| 199 |
-
return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
|
| 200 |
|
| 201 |
-
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 202 |
|
| 203 |
-
|
|
|
|
| 204 |
|
| 205 |
-
|
| 206 |
-
assert
|
| 207 |
-
assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
|
| 208 |
|
| 209 |
|
| 210 |
# ------------------------------------- api_lifespan -------------------------------------
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
|
| 214 |
"""The container runs as a non-root user (see the Dockerfile), and
|
| 215 |
Chromium's own internal sandbox needs privileges a non-root container
|
|
@@ -243,6 +232,10 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
|
|
| 243 |
monkeypatch.setattr(app_module, "pw_browser", None)
|
| 244 |
monkeypatch.setattr(app_module, "playwright", None)
|
| 245 |
monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
async with app_module.api_lifespan(app_module.app):
|
| 248 |
pass
|
|
@@ -251,94 +244,35 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
|
|
| 251 |
assert "--no-sandbox" in launch_calls[0].get("args", [])
|
| 252 |
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
async def test_search_one_returns_first_backend_that_succeeds(monkeypatch):
|
| 258 |
-
calls = []
|
| 259 |
-
|
| 260 |
-
async def fake_ddg(q, n):
|
| 261 |
-
calls.append("ddg")
|
| 262 |
-
return [{"title": "from ddg"}]
|
| 263 |
-
|
| 264 |
-
async def fake_brave(browser, q, n):
|
| 265 |
-
calls.append("brave")
|
| 266 |
-
return [{"title": "from brave"}]
|
| 267 |
-
|
| 268 |
-
monkeypatch.setattr(app_module, "query_ddg_search", fake_ddg)
|
| 269 |
-
monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
|
| 270 |
-
|
| 271 |
-
q, results, err = await app_module._search_one("widget", 5)
|
| 272 |
-
|
| 273 |
-
assert calls == ["ddg"]
|
| 274 |
-
assert results == [{"title": "from ddg"}]
|
| 275 |
-
assert err is None
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
async def test_search_one_falls_through_to_the_next_backend_on_failure(monkeypatch):
|
| 279 |
-
calls = []
|
| 280 |
-
|
| 281 |
-
async def failing_ddg(q, n):
|
| 282 |
-
calls.append("ddg")
|
| 283 |
-
raise RuntimeError("ddg blocked")
|
| 284 |
-
|
| 285 |
-
async def fake_brave(browser, q, n):
|
| 286 |
-
calls.append("brave")
|
| 287 |
-
return [{"title": "from brave"}]
|
| 288 |
-
|
| 289 |
-
monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
|
| 290 |
-
monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
|
| 291 |
-
|
| 292 |
-
q, results, err = await app_module._search_one("widget", 5)
|
| 293 |
-
|
| 294 |
-
assert calls == ["ddg", "brave"]
|
| 295 |
-
assert results == [{"title": "from brave"}]
|
| 296 |
-
assert err is None
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
async def test_search_one_reports_an_error_when_every_backend_fails(monkeypatch):
|
| 300 |
-
async def failing(*args):
|
| 301 |
-
raise RuntimeError("blocked")
|
| 302 |
-
|
| 303 |
-
monkeypatch.setattr(app_module, "query_ddg_search", failing)
|
| 304 |
-
monkeypatch.setattr(app_module, "query_brave_search", failing)
|
| 305 |
-
monkeypatch.setattr(app_module, "query_bing_search", failing)
|
| 306 |
-
|
| 307 |
-
q, results, err = await app_module._search_one("widget", 5)
|
| 308 |
-
|
| 309 |
-
assert results == []
|
| 310 |
-
assert "All backends failed for query 'widget'" in err
|
| 311 |
-
assert "blocked" in err
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
async def test_search_one_skips_a_backend_whose_circuit_is_open(monkeypatch):
|
| 315 |
-
"""A backend that's failed `failure_threshold` times in a row shouldn't
|
| 316 |
-
be attempted again until its cooldown elapses - repeatedly hammering a
|
| 317 |
-
backend that's already rate-limiting us only makes that worse.
|
| 318 |
"""
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
monkeypatch.setattr(app_module, "
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
| 325 |
|
| 326 |
-
async
|
| 327 |
-
|
| 328 |
-
raise RuntimeError("ddg blocked")
|
| 329 |
|
| 330 |
-
|
| 331 |
-
return [{"title": "from brave"}]
|
| 332 |
|
| 333 |
-
monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
|
| 334 |
-
monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
q, results, err = await app_module._search_one("second query", 5)
|
| 343 |
-
assert ddg_calls == ["first query"] # unchanged - DDG was skipped
|
| 344 |
-
assert results == [{"title": "from brave"}]
|
|
|
|
| 1 |
+
"""app.py: HTTP wiring only.
|
| 2 |
+
|
| 3 |
+
The orchestration these endpoints used to contain now lives in
|
| 4 |
+
services.py and is tested in test_search_service.py and
|
| 5 |
+
test_patent_fallback.py without an HTTP layer. What's left here is what
|
| 6 |
+
app.py is actually responsible for: request validation, dependency wiring,
|
| 7 |
+
the lifespan, and turning domain results into responses.
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import httpx
|
|
|
|
| 12 |
from httpx import ASGITransport
|
| 13 |
|
| 14 |
import app as app_module
|
| 15 |
+
from scrap import PatentScrapBulkResponse, PatentScrapResult
|
| 16 |
+
from serp import SerpResults
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@pytest.fixture
|
|
|
|
| 23 |
yield c
|
| 24 |
|
| 25 |
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def override_services():
|
| 28 |
+
"""Install stand-in services for the duration of one test."""
|
| 29 |
+
def _install(*, search=None, patent=None):
|
| 30 |
+
if search is not None:
|
| 31 |
+
app_module.app.dependency_overrides[app_module.get_search_service] = lambda: search
|
| 32 |
+
if patent is not None:
|
| 33 |
+
app_module.app.dependency_overrides[app_module.get_patent_service] = lambda: patent
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
yield _install
|
| 36 |
+
app_module.app.dependency_overrides.clear()
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
class _RecordingSearchService:
|
| 40 |
+
"""Records which service method each endpoint dispatched to."""
|
| 41 |
|
| 42 |
+
def __init__(self):
|
| 43 |
+
self.calls = []
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
def _record(self, name):
|
| 46 |
+
async def _fn(params):
|
| 47 |
+
self.calls.append((name, list(params.queries), params.n_results))
|
| 48 |
+
return SerpResults(results=[{"title": name}], error=None)
|
| 49 |
+
return _fn
|
| 50 |
|
| 51 |
+
def __getattr__(self, name):
|
| 52 |
+
return self._record(name)
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
+
class _StubPatentService:
|
| 56 |
+
def __init__(self):
|
| 57 |
+
self.calls = []
|
| 58 |
|
| 59 |
+
async def scrap(self, patent_id):
|
| 60 |
+
self.calls.append(("scrap", patent_id))
|
| 61 |
+
return PatentScrapResult(title="Widget apparatus")
|
| 62 |
|
| 63 |
+
async def scrap_bulk(self, patent_ids):
|
| 64 |
+
self.calls.append(("scrap_bulk", patent_ids))
|
| 65 |
+
return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))
|
| 66 |
|
| 67 |
+
async def ops_scrap(self, patent_id):
|
| 68 |
+
self.calls.append(("ops_scrap", patent_id))
|
| 69 |
+
return PatentScrapResult(title="From OPS")
|
| 70 |
|
| 71 |
+
async def ops_scrap_bulk(self, patent_ids):
|
| 72 |
+
self.calls.append(("ops_scrap_bulk", patent_ids))
|
| 73 |
+
return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))
|
| 74 |
|
|
|
|
| 75 |
|
| 76 |
+
# ------------------------------- endpoint dispatch -------------------------------
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
@pytest.mark.parametrize("path, expected_method", [
|
| 80 |
+
("/serp/search_scholar", "google_scholar"),
|
| 81 |
+
("/serp/search_arxiv", "arxiv"),
|
| 82 |
+
("/serp/search_patents", "patents"),
|
| 83 |
+
("/serp/search_brave", "brave"),
|
| 84 |
+
("/serp/search_bing", "bing"),
|
| 85 |
+
("/serp/search_duck", "duckduckgo"),
|
| 86 |
+
("/serp/search", "search"),
|
| 87 |
+
("/ops/search", "ops_keyword_search"),
|
| 88 |
+
])
|
| 89 |
+
async def test_each_search_endpoint_dispatches_to_its_service_method(
|
| 90 |
+
client, override_services, path, expected_method):
|
| 91 |
+
"""Guards the wiring itself: every endpoint reaching the right method
|
| 92 |
+
with the request's queries and result count intact."""
|
| 93 |
+
service = _RecordingSearchService()
|
| 94 |
+
override_services(search=service)
|
| 95 |
|
| 96 |
+
resp = await client.post(path, json={"queries": ["a", "b"], "n_results": 25})
|
| 97 |
|
| 98 |
assert resp.status_code == 200
|
| 99 |
+
assert service.calls == [(expected_method, ["a", "b"], 25)]
|
| 100 |
+
assert resp.json()["results"] == [{"title": expected_method}]
|
|
|
|
| 101 |
|
| 102 |
|
| 103 |
+
@pytest.mark.parametrize("method, path, expected_call", [
|
| 104 |
+
("get", "/scrap/scrap_patent/US11930446B2", ("scrap", "US11930446B2")),
|
| 105 |
+
("get", "/ops/scrap_patent/US11930446B2", ("ops_scrap", "US11930446B2")),
|
| 106 |
+
])
|
| 107 |
+
async def test_patent_endpoints_dispatch_to_their_service_method(
|
| 108 |
+
client, override_services, method, path, expected_call):
|
| 109 |
+
service = _StubPatentService()
|
| 110 |
+
override_services(patent=service)
|
| 111 |
|
| 112 |
+
resp = await getattr(client, method)(path)
|
|
|
|
|
|
|
| 113 |
|
| 114 |
assert resp.status_code == 200
|
| 115 |
+
assert service.calls == [expected_call]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
@pytest.mark.parametrize("path, expected_method", [
|
| 119 |
+
("/scrap/scrap_patents_bulk", "scrap_bulk"),
|
| 120 |
+
("/ops/scrap_patents_bulk", "ops_scrap_bulk"),
|
| 121 |
+
])
|
| 122 |
+
async def test_bulk_endpoints_dispatch_to_their_service_method(
|
| 123 |
+
client, override_services, path, expected_method):
|
| 124 |
+
service = _StubPatentService()
|
| 125 |
+
override_services(patent=service)
|
| 126 |
|
| 127 |
+
resp = await client.post(path, json={"patent_ids": ["US11930446B2"]})
|
| 128 |
|
| 129 |
+
assert resp.status_code == 200
|
| 130 |
+
assert service.calls == [(expected_method, ["US11930446B2"])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
# ------------------------------- patent_id validation -------------------------------
|
| 134 |
|
| 135 |
|
| 136 |
+
@pytest.mark.parametrize("path", [
|
| 137 |
+
"/scrap/scrap_patent/not-a-patent-id",
|
| 138 |
+
"/ops/scrap_patent/not-a-patent-id",
|
| 139 |
+
])
|
| 140 |
+
async def test_malformed_patent_id_is_rejected(client, path):
|
| 141 |
+
resp = await client.get(path)
|
| 142 |
assert resp.status_code == 422
|
| 143 |
|
| 144 |
|
| 145 |
+
@pytest.mark.parametrize("path", [
|
| 146 |
+
"/scrap/scrap_patents_bulk", "/ops/scrap_patents_bulk"])
|
| 147 |
+
async def test_malformed_patent_id_in_a_bulk_list_is_rejected(client, path):
|
| 148 |
+
resp = await client.post(path, json={"patent_ids": ["US11930446B2", "garbage!!"]})
|
| 149 |
+
assert resp.status_code == 422
|
| 150 |
|
| 151 |
+
|
| 152 |
+
async def test_well_formed_patent_id_is_accepted(client, override_services):
|
| 153 |
+
override_services(patent=_StubPatentService())
|
| 154 |
|
| 155 |
resp = await client.get("/scrap/scrap_patent/US11930446B2")
|
| 156 |
|
|
|
|
| 158 |
assert resp.json()["title"] == "Widget apparatus"
|
| 159 |
|
| 160 |
|
| 161 |
+
# ---------------------------- default dependency wiring ----------------------------
|
|
|
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
+
def test_the_default_search_service_gets_the_apps_collaborators():
|
| 165 |
+
"""The providers exist so tests can override them; this pins that the
|
| 166 |
+
un-overridden ones still hand the service the real collaborators."""
|
| 167 |
+
service = app_module.get_search_service()
|
| 168 |
|
| 169 |
+
assert service._http is app_module.httpx_client
|
| 170 |
+
assert service._breaker is app_module._backend_circuit_breaker
|
| 171 |
+
assert service._ops_tokens is app_module.ops_token_manager
|
| 172 |
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
+
def test_the_search_service_reads_the_browser_lazily(monkeypatch):
|
| 175 |
+
"""The browser is started by the lifespan, after the service may already
|
| 176 |
+
exist, and stays None when Playwright fails to start - so the service
|
| 177 |
+
has to read it per call rather than capture it at construction."""
|
| 178 |
+
service = app_module.get_search_service()
|
| 179 |
+
monkeypatch.setattr(app_module, "pw_browser", "a-browser")
|
| 180 |
|
| 181 |
+
assert service.browser == "a-browser"
|
|
|
|
|
|
|
| 182 |
|
|
|
|
| 183 |
|
| 184 |
+
def test_the_default_patent_service_gets_the_apps_collaborators():
|
| 185 |
+
service = app_module.get_patent_service()
|
| 186 |
|
| 187 |
+
assert service._http is app_module.httpx_client
|
| 188 |
+
assert service._ops_tokens is app_module.ops_token_manager
|
|
|
|
| 189 |
|
| 190 |
|
| 191 |
# ------------------------------------- api_lifespan -------------------------------------
|
| 192 |
|
| 193 |
|
| 194 |
+
class _RecordingClient:
|
| 195 |
+
def __init__(self):
|
| 196 |
+
self.closed = False
|
| 197 |
+
|
| 198 |
+
async def aclose(self):
|
| 199 |
+
self.closed = True
|
| 200 |
+
|
| 201 |
+
|
| 202 |
async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
|
| 203 |
"""The container runs as a non-root user (see the Dockerfile), and
|
| 204 |
Chromium's own internal sandbox needs privileges a non-root container
|
|
|
|
| 232 |
monkeypatch.setattr(app_module, "pw_browser", None)
|
| 233 |
monkeypatch.setattr(app_module, "playwright", None)
|
| 234 |
monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
|
| 235 |
+
# The lifespan also closes the shared httpx client on shutdown; swap in
|
| 236 |
+
# a stand-in so this test doesn't close the real one for the rest of
|
| 237 |
+
# the session.
|
| 238 |
+
monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
|
| 239 |
|
| 240 |
async with app_module.api_lifespan(app_module.app):
|
| 241 |
pass
|
|
|
|
| 244 |
assert "--no-sandbox" in launch_calls[0].get("args", [])
|
| 245 |
|
| 246 |
|
| 247 |
+
async def test_api_lifespan_closes_the_http_client_on_shutdown(monkeypatch):
|
| 248 |
+
"""The client was created at import and never closed - the browser was
|
| 249 |
+
torn down on shutdown but its connection pool was not.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
"""
|
| 251 |
+
recording_client = _RecordingClient()
|
| 252 |
+
monkeypatch.setattr(app_module, "pw_browser", None)
|
| 253 |
+
monkeypatch.setattr(app_module, "playwright", None)
|
| 254 |
+
monkeypatch.setattr(app_module, "httpx_client", recording_client)
|
| 255 |
+
# Playwright failing to start is the documented degraded mode; the
|
| 256 |
+
# client must still be closed cleanly on the way out.
|
| 257 |
+
monkeypatch.setattr(
|
| 258 |
+
app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
|
| 259 |
|
| 260 |
+
async with app_module.api_lifespan(app_module.app):
|
| 261 |
+
pass
|
|
|
|
| 262 |
|
| 263 |
+
assert recording_client.closed is True
|
|
|
|
| 264 |
|
|
|
|
|
|
|
| 265 |
|
| 266 |
+
async def test_api_lifespan_survives_playwright_failing_to_start(monkeypatch):
|
| 267 |
+
"""Documented behaviour: the app still comes up, pw_browser stays None,
|
| 268 |
+
and the browser-backed endpoints fail individually rather than the whole
|
| 269 |
+
deployment refusing to boot.
|
| 270 |
+
"""
|
| 271 |
+
monkeypatch.setattr(app_module, "pw_browser", None)
|
| 272 |
+
monkeypatch.setattr(app_module, "playwright", None)
|
| 273 |
+
monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
|
| 274 |
+
monkeypatch.setattr(
|
| 275 |
+
app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
|
| 276 |
|
| 277 |
+
async with app_module.api_lifespan(app_module.app):
|
| 278 |
+
assert app_module.pw_browser is None
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the optional API-key middleware.
|
| 2 |
+
|
| 3 |
+
This is the only security control in the codebase and had no tests at all:
|
| 4 |
+
mutation testing showed that replacing `secrets.compare_digest` with `==`
|
| 5 |
+
(a timing-oracle regression) or disabling the guard's condition outright
|
| 6 |
+
both left the full suite green. These pin it.
|
| 7 |
+
|
| 8 |
+
`SERPENT_API_KEY` is read into a module global that the middleware resolves
|
| 9 |
+
at call time, so monkeypatching the module attribute is enough - no import
|
| 10 |
+
juggling or module reloading needed.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
import pytest
|
| 15 |
+
from httpx import ASGITransport
|
| 16 |
+
|
| 17 |
+
import app as app_module
|
| 18 |
+
import services as services_module
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
async def client():
|
| 23 |
+
transport = ASGITransport(app=app_module.app)
|
| 24 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 25 |
+
yield c
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def api_key(monkeypatch):
|
| 30 |
+
"""Configure a known key for the duration of one test."""
|
| 31 |
+
monkeypatch.setattr(app_module, "SERPENT_API_KEY", "s3cret-key")
|
| 32 |
+
return "s3cret-key"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture(autouse=True)
|
| 36 |
+
def _unset_by_default(monkeypatch):
|
| 37 |
+
"""Every test starts from the shipped default (no key configured), so a
|
| 38 |
+
stray SERPENT_API_KEY in the developer's environment can't change the
|
| 39 |
+
outcome of the tests below.
|
| 40 |
+
"""
|
| 41 |
+
monkeypatch.setattr(app_module, "SERPENT_API_KEY", None)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ------------------------------- disabled by default -------------------------------
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def test_no_key_configured_leaves_the_api_open(client, monkeypatch):
|
| 48 |
+
"""The middleware is a no-op unless SERPENT_API_KEY is set - this keeps
|
| 49 |
+
the default deployment exactly as open as it was before it existed.
|
| 50 |
+
"""
|
| 51 |
+
monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
|
| 52 |
+
|
| 53 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a"]})
|
| 54 |
+
|
| 55 |
+
assert resp.status_code == 200
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def _stub_arxiv(client_arg, q, n):
|
| 59 |
+
return [{"title": q, "href": "h", "body": "b", "id": "1"}]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ------------------------------- gating when enabled -------------------------------
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def test_missing_key_is_rejected(client, api_key):
|
| 66 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a"]})
|
| 67 |
+
|
| 68 |
+
assert resp.status_code == 401
|
| 69 |
+
assert "API key" in resp.json()["detail"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def test_wrong_key_is_rejected(client, api_key):
|
| 73 |
+
resp = await client.post(
|
| 74 |
+
"/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "wrong"})
|
| 75 |
+
|
| 76 |
+
assert resp.status_code == 401
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_correct_key_via_x_api_key_header_is_accepted(client, api_key, monkeypatch):
|
| 80 |
+
monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
|
| 81 |
+
|
| 82 |
+
resp = await client.post(
|
| 83 |
+
"/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": api_key})
|
| 84 |
+
|
| 85 |
+
assert resp.status_code == 200
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def test_correct_key_via_bearer_header_is_accepted(client, api_key, monkeypatch):
|
| 89 |
+
monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
|
| 90 |
+
|
| 91 |
+
resp = await client.post(
|
| 92 |
+
"/serp/search_arxiv", json={"queries": ["a"]},
|
| 93 |
+
headers={"Authorization": f"Bearer {api_key}"})
|
| 94 |
+
|
| 95 |
+
assert resp.status_code == 200
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def test_bearer_scheme_match_is_case_insensitive(client, api_key, monkeypatch):
|
| 99 |
+
monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
|
| 100 |
+
|
| 101 |
+
resp = await client.post(
|
| 102 |
+
"/serp/search_arxiv", json={"queries": ["a"]},
|
| 103 |
+
headers={"Authorization": f"bearer {api_key}"})
|
| 104 |
+
|
| 105 |
+
assert resp.status_code == 200
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# --------------------------------- public paths ---------------------------------
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@pytest.mark.parametrize("path", ["/", "/openapi.json"])
|
| 112 |
+
async def test_docs_paths_stay_public(client, api_key, path):
|
| 113 |
+
"""The docs UI and the schema it fetches must stay reachable, otherwise
|
| 114 |
+
a key-protected deployment serves an unusable landing page.
|
| 115 |
+
"""
|
| 116 |
+
resp = await client.get(path)
|
| 117 |
+
|
| 118 |
+
assert resp.status_code == 200
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def test_a_key_that_is_a_prefix_of_the_real_one_is_rejected(client, api_key):
|
| 122 |
+
"""Guards the length check inside the comparison."""
|
| 123 |
+
resp = await client.post(
|
| 124 |
+
"/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "s3cret"})
|
| 125 |
+
|
| 126 |
+
assert resp.status_code == 401
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ------------------------------ constant-time comparison ------------------------------
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def test_key_comparison_is_constant_time(client, api_key, monkeypatch):
|
| 133 |
+
"""`==` and `secrets.compare_digest` are functionally identical - they
|
| 134 |
+
differ only in timing, so no black-box assertion can tell them apart.
|
| 135 |
+
(Confirmed by mutation: swapping in `==` leaves every other test in this
|
| 136 |
+
file green.) A timing oracle on the API key is worth pinning anyway, so
|
| 137 |
+
assert structurally that the comparison goes through compare_digest.
|
| 138 |
+
"""
|
| 139 |
+
import secrets as secrets_module
|
| 140 |
+
|
| 141 |
+
calls = []
|
| 142 |
+
real = secrets_module.compare_digest
|
| 143 |
+
|
| 144 |
+
def spy(a, b):
|
| 145 |
+
calls.append((a, b))
|
| 146 |
+
return real(a, b)
|
| 147 |
+
|
| 148 |
+
monkeypatch.setattr(secrets_module, "compare_digest", spy)
|
| 149 |
+
|
| 150 |
+
await client.post(
|
| 151 |
+
"/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "wrong"})
|
| 152 |
+
|
| 153 |
+
assert calls, "the API key must be compared with secrets.compare_digest"
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def test_non_ascii_key_is_rejected_not_crashed(client, api_key):
|
| 157 |
+
"""`secrets.compare_digest` raises TypeError ("comparing strings with
|
| 158 |
+
non-ASCII characters is not supported") on str arguments outside ASCII,
|
| 159 |
+
and Starlette decodes headers as latin-1 - so an accented byte in the
|
| 160 |
+
header reached it intact and turned the security control into an
|
| 161 |
+
unhandled 500 for any anonymous caller. It must fail closed with a 401
|
| 162 |
+
like any other wrong key.
|
| 163 |
+
|
| 164 |
+
The header is passed as raw latin-1 bytes because that is what arrives
|
| 165 |
+
off the wire; handing httpx a str with non-ASCII in it fails in the
|
| 166 |
+
client before the request is ever sent, which would test nothing.
|
| 167 |
+
"""
|
| 168 |
+
resp = await client.post(
|
| 169 |
+
"/serp/search_arxiv", json={"queries": ["a"]},
|
| 170 |
+
headers={"X-API-Key": "café".encode("latin-1")})
|
| 171 |
+
|
| 172 |
+
assert resp.status_code == 401
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def test_non_ascii_key_is_rejected_via_bearer_header_too(client, api_key):
|
| 176 |
+
resp = await client.post(
|
| 177 |
+
"/serp/search_arxiv", json={"queries": ["a"]},
|
| 178 |
+
headers={"Authorization": "Bearer café".encode("latin-1")})
|
| 179 |
+
|
| 180 |
+
assert resp.status_code == 401
|
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounds on request-shaped input.
|
| 2 |
+
|
| 3 |
+
`n_results` was defended by a validator and a parametrized test while the
|
| 4 |
+
list fields on the same models were undefended entirely: an empty query
|
| 5 |
+
list crashed every search endpoint with an IndexError, and the bulk
|
| 6 |
+
endpoints would fan out one concurrent outbound request per id with no
|
| 7 |
+
upper limit at all.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
import pytest
|
| 12 |
+
from httpx import ASGITransport
|
| 13 |
+
from pydantic import ValidationError
|
| 14 |
+
|
| 15 |
+
import app as app_module
|
| 16 |
+
import scrap as scrap_module
|
| 17 |
+
from app import MAX_BULK_PATENT_IDS, ScrapPatentsRequest
|
| 18 |
+
from serp import MAX_QUERIES_PER_REQUEST, SerpQuery
|
| 19 |
+
from services import shape_serp_results
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
async def client():
|
| 24 |
+
transport = ASGITransport(app=app_module.app)
|
| 25 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 26 |
+
yield c
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# --------------------------------- query list bounds ---------------------------------
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_empty_query_list_is_rejected_by_the_model():
|
| 33 |
+
with pytest.raises(ValidationError):
|
| 34 |
+
SerpQuery(queries=[])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_too_many_queries_are_rejected_by_the_model():
|
| 38 |
+
with pytest.raises(ValidationError):
|
| 39 |
+
SerpQuery(queries=["q"] * (MAX_QUERIES_PER_REQUEST + 1))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def test_empty_query_list_returns_422_not_500(client):
|
| 43 |
+
"""`_shape_serp_results` read `results[-1]` to build its error message,
|
| 44 |
+
so an empty list raised IndexError - an unhandled 500 on all six search
|
| 45 |
+
endpoints, reachable with a two-character request body.
|
| 46 |
+
"""
|
| 47 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": []})
|
| 48 |
+
|
| 49 |
+
assert resp.status_code == 422
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def test_oversized_query_list_returns_422(client):
|
| 53 |
+
resp = await client.post(
|
| 54 |
+
"/serp/search_arxiv", json={"queries": ["q"] * (MAX_QUERIES_PER_REQUEST + 1)})
|
| 55 |
+
|
| 56 |
+
assert resp.status_code == 422
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_shape_serp_results_is_total_for_an_empty_list():
|
| 60 |
+
"""Defence in depth: the helper must not depend on its caller having
|
| 61 |
+
validated the query list, since it is shared by every search path.
|
| 62 |
+
"""
|
| 63 |
+
result = shape_serp_results([])
|
| 64 |
+
|
| 65 |
+
assert result.results == []
|
| 66 |
+
assert result.error is not None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------- bulk id bounds ----------------------------------
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_empty_patent_id_list_is_rejected():
|
| 73 |
+
with pytest.raises(ValidationError):
|
| 74 |
+
ScrapPatentsRequest(patent_ids=[])
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_too_many_patent_ids_are_rejected():
|
| 78 |
+
with pytest.raises(ValidationError):
|
| 79 |
+
ScrapPatentsRequest(patent_ids=["US1234567"] * (MAX_BULK_PATENT_IDS + 1))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def test_oversized_bulk_request_returns_422(client):
|
| 83 |
+
resp = await client.post(
|
| 84 |
+
"/scrap/scrap_patents_bulk",
|
| 85 |
+
json={"patent_ids": ["US1234567"] * (MAX_BULK_PATENT_IDS + 1)})
|
| 86 |
+
|
| 87 |
+
assert resp.status_code == 422
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ------------------------------- bulk fan-out is bounded -------------------------------
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def test_bulk_scrape_limits_concurrent_outbound_requests(monkeypatch):
|
| 94 |
+
"""Playwright work is bounded by a semaphore; HTTP work was not bounded
|
| 95 |
+
at all, so a single accepted request could open one outbound scrape per
|
| 96 |
+
id. Track how many are in flight simultaneously.
|
| 97 |
+
"""
|
| 98 |
+
import asyncio
|
| 99 |
+
|
| 100 |
+
in_flight = 0
|
| 101 |
+
peak = 0
|
| 102 |
+
|
| 103 |
+
async def slow_scrape(client_arg, url):
|
| 104 |
+
nonlocal in_flight, peak
|
| 105 |
+
in_flight += 1
|
| 106 |
+
peak = max(peak, in_flight)
|
| 107 |
+
await asyncio.sleep(0.01)
|
| 108 |
+
in_flight -= 1
|
| 109 |
+
raise httpx.ConnectTimeout("nope")
|
| 110 |
+
|
| 111 |
+
monkeypatch.setattr(scrap_module, "scrap_patent_async", slow_scrape)
|
| 112 |
+
|
| 113 |
+
ids = [f"US{1000000 + i}" for i in range(40)]
|
| 114 |
+
result = await scrap_module.scrap_patent_bulk_async(None, ids)
|
| 115 |
+
|
| 116 |
+
assert peak <= scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT, (
|
| 117 |
+
f"{peak} concurrent scrapes for {len(ids)} ids; "
|
| 118 |
+
f"limit is {scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT}")
|
| 119 |
+
assert result.failed_ids == ids
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def test_bulk_ops_retrieval_limits_concurrent_requests(monkeypatch):
|
| 123 |
+
"""The OPS bulk path is the expensive one - three requests per id
|
| 124 |
+
(biblio, claims, description) - so it carries a tighter limit.
|
| 125 |
+
"""
|
| 126 |
+
import asyncio
|
| 127 |
+
|
| 128 |
+
import ops as ops_module
|
| 129 |
+
|
| 130 |
+
in_flight = 0
|
| 131 |
+
peak = 0
|
| 132 |
+
|
| 133 |
+
async def slow_ops_scrap(client_arg, number, *args, **kwargs):
|
| 134 |
+
nonlocal in_flight, peak
|
| 135 |
+
in_flight += 1
|
| 136 |
+
peak = max(peak, in_flight)
|
| 137 |
+
await asyncio.sleep(0.01)
|
| 138 |
+
in_flight -= 1
|
| 139 |
+
raise RuntimeError("nope")
|
| 140 |
+
|
| 141 |
+
monkeypatch.setattr(ops_module, "ops_scrap_patent", slow_ops_scrap)
|
| 142 |
+
|
| 143 |
+
numbers = [f"US{1000000 + i}" for i in range(30)]
|
| 144 |
+
result = await ops_module.ops_scrap_patent_bulk(None, numbers)
|
| 145 |
+
|
| 146 |
+
assert peak <= ops_module.BULK_OPS_CONCURRENCY_LIMIT, (
|
| 147 |
+
f"{peak} concurrent OPS retrievals; limit is "
|
| 148 |
+
f"{ops_module.BULK_OPS_CONCURRENCY_LIMIT}")
|
| 149 |
+
assert result.failed_ids == numbers
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The MCP tool surface.
|
| 2 |
+
|
| 3 |
+
mcp_server.py had no tests, and it degrades silently. `_tool_names` walks
|
| 4 |
+
the route tree through three version-dependent shapes - DefaultPlaceholder
|
| 5 |
+
unwrapping, nested `.routes`, and FastAPI >= 0.141's `original_router` -
|
| 6 |
+
each a reasonable guess about a library's internals. If any stops
|
| 7 |
+
resolving, tools quietly revert to names like
|
| 8 |
+
`search_arxiv_serp_search_arxiv_post`, tool selection degrades, and
|
| 9 |
+
nothing goes red.
|
| 10 |
+
|
| 11 |
+
These pin the surface an agent actually sees, so a dependency bump reports
|
| 12 |
+
the breakage instead of shipping it.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
import app as app_module
|
| 18 |
+
from mcp_server import EXCLUDED_ROUTES, _tool_names
|
| 19 |
+
|
| 20 |
+
# The curated set. /ops/* and the single-backend search endpoints are
|
| 21 |
+
# deliberately excluded (see EXCLUDED_ROUTES): they stay available over
|
| 22 |
+
# REST, but exposing them as tools invites a model to pick a path that
|
| 23 |
+
# skips the fallback chains.
|
| 24 |
+
EXPECTED_TOOLS = {
|
| 25 |
+
"search",
|
| 26 |
+
"search_arxiv",
|
| 27 |
+
"search_google_scholar",
|
| 28 |
+
"search_patents",
|
| 29 |
+
"scrap_patent",
|
| 30 |
+
"scrap_patents",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
async def tools():
|
| 36 |
+
return {t.name: t for t in await app_module.mcp.list_tools()}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def test_the_exposed_tool_set_is_the_curated_one(tools):
|
| 40 |
+
assert set(tools) == EXPECTED_TOOLS
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def test_tool_names_are_handler_names_not_generated_operation_ids(tools):
|
| 44 |
+
"""FastAPI's generated operationIds (`search_arxiv_serp_search_arxiv_post`)
|
| 45 |
+
are what an LLM would otherwise see. _tool_names maps them back to the
|
| 46 |
+
Python handler name.
|
| 47 |
+
"""
|
| 48 |
+
for name in tools:
|
| 49 |
+
assert "_post" not in name and "_get" not in name
|
| 50 |
+
assert "_serp_" not in name and "_scrap_" not in name
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def test_operation_ids_all_resolve_to_a_handler_name():
|
| 54 |
+
"""If the route walk stops finding routes, this mapping silently
|
| 55 |
+
empties and every tool falls back to its generated operationId."""
|
| 56 |
+
names = _tool_names(app_module.app)
|
| 57 |
+
|
| 58 |
+
assert len(names) >= len(EXPECTED_TOOLS)
|
| 59 |
+
assert "search_arxiv_serp_search_arxiv_post" in names
|
| 60 |
+
assert names["search_arxiv_serp_search_arxiv_post"] == "search_arxiv"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.parametrize("excluded", [
|
| 64 |
+
"ops_keyword_search", "ops_get_patent", "ops_get_patents_bulk",
|
| 65 |
+
"search_brave", "search_bing", "search_duck",
|
| 66 |
+
])
|
| 67 |
+
async def test_excluded_endpoints_are_not_exposed_as_tools(tools, excluded):
|
| 68 |
+
assert excluded not in tools
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_excluded_routes_patterns_are_anchored():
|
| 72 |
+
"""An unanchored pattern would silently exclude more than intended."""
|
| 73 |
+
for pattern in EXCLUDED_ROUTES:
|
| 74 |
+
assert pattern.startswith("^")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------- output schemas ----------------------------------
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOLS))
|
| 81 |
+
async def test_every_tool_declares_its_output_schema(tools, tool_name):
|
| 82 |
+
"""FastMCP builds tool schemas from the app's OpenAPI document, so a
|
| 83 |
+
handler with no return annotation advertises an untyped result to the
|
| 84 |
+
model. `search` - the tool the instructions push agents toward first -
|
| 85 |
+
was one of three that did.
|
| 86 |
+
"""
|
| 87 |
+
assert tools[tool_name].output_schema, (
|
| 88 |
+
f"tool '{tool_name}' advertises no output schema; "
|
| 89 |
+
"the handler is probably missing a return annotation")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.parametrize("path", [
|
| 93 |
+
"/serp/search", "/serp/search_arxiv", "/serp/search_scholar",
|
| 94 |
+
"/serp/search_patents", "/scrap/scrap_patent/{patent_id}",
|
| 95 |
+
"/scrap/scrap_patents_bulk",
|
| 96 |
+
])
|
| 97 |
+
def test_every_exposed_route_declares_a_200_response_model(path):
|
| 98 |
+
schema = app_module.app.openapi()
|
| 99 |
+
for operation in schema["paths"][path].values():
|
| 100 |
+
content = (operation.get("responses", {}).get("200", {})
|
| 101 |
+
.get("content", {}).get("application/json", {}).get("schema", {}))
|
| 102 |
+
assert content, f"{path} has no 200 response schema"
|
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Google Patents -> EPO OPS fallback chain.
|
| 2 |
+
|
| 3 |
+
The policy lives in services.PatentService, so most of this is plain unit
|
| 4 |
+
testing with no ASGI client involved. Only the last section goes through
|
| 5 |
+
the app, and only to check that the domain errors the service raises map
|
| 6 |
+
onto the right status codes.
|
| 7 |
+
|
| 8 |
+
The distinction these tests exist to protect: a 404 means "this patent does
|
| 9 |
+
not exist", and an agent is explicitly told by MCP_INSTRUCTIONS to believe
|
| 10 |
+
it and move on without retrying. Anything else - an upstream outage, a
|
| 11 |
+
timeout, a blocked scrape - must not be reported that way.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
import pytest
|
| 16 |
+
from httpx import ASGITransport, HTTPStatusError, Request, Response
|
| 17 |
+
|
| 18 |
+
import app as app_module
|
| 19 |
+
import services as services_module
|
| 20 |
+
from scrap import PatentScrapResult
|
| 21 |
+
from services import (OPSUnconfigured, PatentNotFound, PatentService,
|
| 22 |
+
UpstreamUnavailable)
|
| 23 |
+
|
| 24 |
+
PATENT_ID = "US11930446B2"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class FakeOPSTokens:
|
| 28 |
+
"""Stand-in for the OPS token manager. Tests only care whether
|
| 29 |
+
credentials are configured, and this avoids assigning to private
|
| 30 |
+
attributes on the real module-level singleton.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, configured: bool):
|
| 34 |
+
self.configured = configured
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def make_service(*, ops_configured: bool) -> PatentService:
|
| 38 |
+
return PatentService(http_client=None, ops_tokens=FakeOPSTokens(ops_configured))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _http_error(status: int) -> HTTPStatusError:
|
| 42 |
+
request = Request("GET", "https://patents.google.com/")
|
| 43 |
+
return HTTPStatusError(
|
| 44 |
+
str(status), request=request, response=Response(status, request=request))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _raises(exc):
|
| 48 |
+
async def _fn(*args, **kwargs):
|
| 49 |
+
raise exc
|
| 50 |
+
return _fn
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _returns(value):
|
| 54 |
+
async def _fn(*args, **kwargs):
|
| 55 |
+
return value
|
| 56 |
+
return _fn
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ------------------------------- the happy path -------------------------------
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def test_successful_scrape_never_touches_ops(monkeypatch):
|
| 63 |
+
ops_called = False
|
| 64 |
+
|
| 65 |
+
async def fake_ops(*args, **kwargs):
|
| 66 |
+
nonlocal ops_called
|
| 67 |
+
ops_called = True
|
| 68 |
+
return PatentScrapResult(title="from OPS")
|
| 69 |
+
|
| 70 |
+
monkeypatch.setattr(
|
| 71 |
+
services_module, "scrap_patent_async",
|
| 72 |
+
_returns(PatentScrapResult(title="from Google Patents")))
|
| 73 |
+
monkeypatch.setattr(services_module, "ops_scrap_patent", fake_ops)
|
| 74 |
+
|
| 75 |
+
result = await make_service(ops_configured=True).scrap(PATENT_ID)
|
| 76 |
+
|
| 77 |
+
assert result.title == "from Google Patents"
|
| 78 |
+
assert ops_called is False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ------------------------- genuine miss vs upstream failure -------------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_a_real_404_from_google_patents_is_not_found(monkeypatch):
|
| 85 |
+
"""The one case where "not found" is the truth."""
|
| 86 |
+
monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 87 |
+
|
| 88 |
+
with pytest.raises(PatentNotFound):
|
| 89 |
+
await make_service(ops_configured=False).scrap(PATENT_ID)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.parametrize("status", [429, 500, 502, 503])
|
| 93 |
+
async def test_an_upstream_failure_is_not_reported_as_not_found(monkeypatch, status):
|
| 94 |
+
"""MCP_INSTRUCTIONS tells the model a "not found" patent is genuinely
|
| 95 |
+
absent everywhere and to move on rather than retrying. Reporting a
|
| 96 |
+
transient upstream failure that way teaches an agent - with the
|
| 97 |
+
server's explicit encouragement - that a real patent does not exist.
|
| 98 |
+
"""
|
| 99 |
+
monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(status)))
|
| 100 |
+
|
| 101 |
+
with pytest.raises(UpstreamUnavailable) as exc_info:
|
| 102 |
+
await make_service(ops_configured=False).scrap(PATENT_ID)
|
| 103 |
+
|
| 104 |
+
assert exc_info.value.timeout is False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def test_a_timeout_is_flagged_as_a_timeout(monkeypatch):
|
| 108 |
+
monkeypatch.setattr(
|
| 109 |
+
services_module, "scrap_patent_async", _raises(httpx.ConnectTimeout("timed out")))
|
| 110 |
+
|
| 111 |
+
with pytest.raises(UpstreamUnavailable) as exc_info:
|
| 112 |
+
await make_service(ops_configured=False).scrap(PATENT_ID)
|
| 113 |
+
|
| 114 |
+
assert exc_info.value.timeout is True
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def test_an_unparseable_page_is_an_upstream_failure(monkeypatch):
|
| 118 |
+
"""parse_patent_html raises ValueError when the page isn't a patent page
|
| 119 |
+
(an interstitial, or a markup change). That is our problem or theirs,
|
| 120 |
+
but it is not evidence the patent doesn't exist.
|
| 121 |
+
"""
|
| 122 |
+
monkeypatch.setattr(
|
| 123 |
+
services_module, "scrap_patent_async", _raises(ValueError("no DC.title")))
|
| 124 |
+
|
| 125 |
+
with pytest.raises(UpstreamUnavailable):
|
| 126 |
+
await make_service(ops_configured=False).scrap(PATENT_ID)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# --------------------------------- the OPS fallback ---------------------------------
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def test_falls_back_to_ops_when_google_patents_fails(monkeypatch):
|
| 133 |
+
monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(503)))
|
| 134 |
+
monkeypatch.setattr(
|
| 135 |
+
services_module, "ops_scrap_patent", _returns(PatentScrapResult(title="from OPS")))
|
| 136 |
+
|
| 137 |
+
result = await make_service(ops_configured=True).scrap(PATENT_ID)
|
| 138 |
+
|
| 139 |
+
assert result.title == "from OPS"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def test_missing_from_both_backends_is_not_found(monkeypatch):
|
| 143 |
+
monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 144 |
+
monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(404)))
|
| 145 |
+
|
| 146 |
+
with pytest.raises(PatentNotFound):
|
| 147 |
+
await make_service(ops_configured=True).scrap(PATENT_ID)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_ops_failing_after_a_google_patents_404_is_not_a_miss(monkeypatch):
|
| 151 |
+
"""Google Patents says the patent is missing, but OPS - the backend that
|
| 152 |
+
covers what Google Patents doesn't - never answered. That is unknown,
|
| 153 |
+
not absent.
|
| 154 |
+
"""
|
| 155 |
+
monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 156 |
+
monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(500)))
|
| 157 |
+
|
| 158 |
+
with pytest.raises(UpstreamUnavailable):
|
| 159 |
+
await make_service(ops_configured=True).scrap(PATENT_ID)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ------------------------------ the OPS-only path ------------------------------
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def test_ops_only_retrieval_requires_credentials():
|
| 166 |
+
with pytest.raises(OPSUnconfigured):
|
| 167 |
+
await make_service(ops_configured=False).ops_scrap(PATENT_ID)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def test_ops_only_retrieval_reports_a_real_miss(monkeypatch):
|
| 171 |
+
"""Here OPS is the only backend asked, so its 404 is the whole answer."""
|
| 172 |
+
monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(404)))
|
| 173 |
+
|
| 174 |
+
with pytest.raises(PatentNotFound):
|
| 175 |
+
await make_service(ops_configured=True).ops_scrap(PATENT_ID)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def test_ops_only_retrieval_flags_a_timeout(monkeypatch):
|
| 179 |
+
monkeypatch.setattr(
|
| 180 |
+
services_module, "ops_scrap_patent", _raises(httpx.ConnectTimeout("timed out")))
|
| 181 |
+
|
| 182 |
+
with pytest.raises(UpstreamUnavailable) as exc_info:
|
| 183 |
+
await make_service(ops_configured=True).ops_scrap(PATENT_ID)
|
| 184 |
+
|
| 185 |
+
assert exc_info.value.timeout is True
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------- domain error -> status code ----------------------------
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@pytest.fixture
|
| 192 |
+
async def client():
|
| 193 |
+
transport = ASGITransport(app=app_module.app)
|
| 194 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 195 |
+
yield c
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@pytest.fixture
|
| 199 |
+
def override_patent_service():
|
| 200 |
+
"""Swap the app's patent service for one the test controls, then put the
|
| 201 |
+
real provider back."""
|
| 202 |
+
def _install(service):
|
| 203 |
+
app_module.app.dependency_overrides[app_module.get_patent_service] = lambda: service
|
| 204 |
+
|
| 205 |
+
yield _install
|
| 206 |
+
app_module.app.dependency_overrides.clear()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class _StubService:
|
| 210 |
+
def __init__(self, error):
|
| 211 |
+
self._error = error
|
| 212 |
+
|
| 213 |
+
async def scrap(self, patent_id):
|
| 214 |
+
raise self._error
|
| 215 |
+
|
| 216 |
+
ops_scrap = scrap
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@pytest.mark.parametrize("error, expected_status", [
|
| 220 |
+
(PatentNotFound("gone"), 404),
|
| 221 |
+
(UpstreamUnavailable("upstream broke"), 502),
|
| 222 |
+
(UpstreamUnavailable("slow", timeout=True), 504),
|
| 223 |
+
(OPSUnconfigured("no credentials"), 503),
|
| 224 |
+
])
|
| 225 |
+
async def test_domain_errors_map_onto_status_codes(
|
| 226 |
+
client, override_patent_service, error, expected_status):
|
| 227 |
+
override_patent_service(_StubService(error))
|
| 228 |
+
|
| 229 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 230 |
+
|
| 231 |
+
assert resp.status_code == expected_status
|
| 232 |
+
assert resp.json()["detail"] == str(error)
|
|
@@ -89,3 +89,15 @@ async def test_scrap_patent_bulk_async_separates_successes_from_failures():
|
|
| 89 |
assert len(result.patents) == 1
|
| 90 |
assert result.patents[0].title == "Widget with improved gadget mechanism"
|
| 91 |
assert result.failed_ids == ["US_FAIL"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
assert len(result.patents) == 1
|
| 90 |
assert result.patents[0].title == "Widget with improved gadget mechanism"
|
| 91 |
assert result.failed_ids == ["US_FAIL"]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_parse_patent_html_strips_whitespace_around_the_title():
|
| 95 |
+
"""Google Patents' DC.title meta content is frequently padded with
|
| 96 |
+
newlines and indentation from the surrounding markup, which would
|
| 97 |
+
otherwise end up in the API response and in every downstream citation.
|
| 98 |
+
"""
|
| 99 |
+
html = '<html><head><meta name="DC.title" content=" Widget apparatus\n "></head><body></body></html>'
|
| 100 |
+
|
| 101 |
+
result = parse_patent_html(html, "https://patents.google.com/patent/US1/en")
|
| 102 |
+
|
| 103 |
+
assert result.title == "Widget apparatus"
|
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SearchService: the backend orchestration policy.
|
| 2 |
+
|
| 3 |
+
Which backend to try, in what order, when to fall back, and what counts as
|
| 4 |
+
evidence a backend is unhealthy. None of this needs an HTTP layer, so none
|
| 5 |
+
of these tests use one.
|
| 6 |
+
|
| 7 |
+
Each test builds a service with its own circuit breaker. That replaces the
|
| 8 |
+
autouse fixture that used to reset a process-wide singleton between tests -
|
| 9 |
+
the breaker being shared across every request is correct in production and
|
| 10 |
+
was only a problem because tests had no way to opt out of it.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
import services as services_module
|
| 15 |
+
from circuit_breaker import CircuitBreaker
|
| 16 |
+
from serp import DuckDuckGoBlockedException, SerpQuery
|
| 17 |
+
from services import SearchService, shape_serp_results
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class FakeOPSTokens:
|
| 21 |
+
def __init__(self, configured: bool):
|
| 22 |
+
self.configured = configured
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def make_service(*, ops_configured: bool = False, breaker: CircuitBreaker = None,
|
| 26 |
+
browser=None) -> SearchService:
|
| 27 |
+
return SearchService(
|
| 28 |
+
http_client=None,
|
| 29 |
+
browser_provider=lambda: browser,
|
| 30 |
+
circuit_breaker=breaker or CircuitBreaker(),
|
| 31 |
+
ops_tokens=FakeOPSTokens(ops_configured),
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --------------------------------- result shaping ---------------------------------
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestShapeSerpResults:
|
| 39 |
+
def test_flattens_successful_lists(self):
|
| 40 |
+
result = shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
|
| 41 |
+
assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
|
| 42 |
+
assert result.error is None
|
| 43 |
+
|
| 44 |
+
def test_surfaces_last_error_when_all_failed(self):
|
| 45 |
+
result = shape_serp_results([ValueError("first"), RuntimeError("second")])
|
| 46 |
+
assert result.results == []
|
| 47 |
+
assert "second" in result.error
|
| 48 |
+
|
| 49 |
+
def test_ignores_failures_when_some_succeed(self):
|
| 50 |
+
result = shape_serp_results([[{"a": 1}], RuntimeError("boom")])
|
| 51 |
+
assert result.results == [{"a": 1}]
|
| 52 |
+
assert result.error is None
|
| 53 |
+
|
| 54 |
+
def test_is_total_for_an_empty_list(self):
|
| 55 |
+
"""Defence in depth: the request models bound the query list, but
|
| 56 |
+
this helper is shared by every search path and must not depend on
|
| 57 |
+
its caller having validated anything."""
|
| 58 |
+
result = shape_serp_results([])
|
| 59 |
+
assert result.results == []
|
| 60 |
+
assert result.error is not None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ------------------------------- per-query dispatch -------------------------------
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def test_every_query_is_dispatched_with_the_result_count():
|
| 67 |
+
calls = []
|
| 68 |
+
|
| 69 |
+
async def fn(q, n):
|
| 70 |
+
calls.append((q, n))
|
| 71 |
+
return [{"q": q}]
|
| 72 |
+
|
| 73 |
+
result = await make_service()._run_queries(
|
| 74 |
+
fn, SerpQuery(queries=["x", "y"], n_results=5), "test context")
|
| 75 |
+
|
| 76 |
+
assert sorted(calls) == [("x", 5), ("y", 5)]
|
| 77 |
+
assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def test_arxiv_flattens_results_from_all_queries(monkeypatch):
|
| 81 |
+
async def fake_query_arxiv(client, q, n):
|
| 82 |
+
return [{"title": f"paper about {q}"}]
|
| 83 |
+
|
| 84 |
+
monkeypatch.setattr(services_module, "query_arxiv", fake_query_arxiv)
|
| 85 |
+
|
| 86 |
+
result = await make_service().arxiv(SerpQuery(queries=["a", "b"], n_results=5))
|
| 87 |
+
|
| 88 |
+
assert {r["title"] for r in result.results} == {"paper about a", "paper about b"}
|
| 89 |
+
assert result.error is None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def test_arxiv_reports_an_error_when_every_query_fails(monkeypatch):
|
| 93 |
+
async def failing(client, q, n):
|
| 94 |
+
raise RuntimeError(f"boom for {q}")
|
| 95 |
+
|
| 96 |
+
monkeypatch.setattr(services_module, "query_arxiv", failing)
|
| 97 |
+
|
| 98 |
+
result = await make_service().arxiv(SerpQuery(queries=["a"], n_results=5))
|
| 99 |
+
|
| 100 |
+
assert result.results == []
|
| 101 |
+
assert "boom for a" in result.error
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ------------------------------ patents + OPS fallback ------------------------------
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def test_patents_does_not_fall_back_when_google_patents_succeeds(monkeypatch):
|
| 108 |
+
ops_called = False
|
| 109 |
+
|
| 110 |
+
async def fake_ops(client, q, n):
|
| 111 |
+
nonlocal ops_called
|
| 112 |
+
ops_called = True
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
monkeypatch.setattr(
|
| 116 |
+
services_module, "query_google_patents",
|
| 117 |
+
lambda b, q, n: _ok([{"id": "US1", "title": "t"}]))
|
| 118 |
+
monkeypatch.setattr(services_module, "ops_search", fake_ops)
|
| 119 |
+
|
| 120 |
+
result = await make_service(ops_configured=True).patents(
|
| 121 |
+
SerpQuery(queries=["widget"], n_results=5))
|
| 122 |
+
|
| 123 |
+
assert result.results == [{"id": "US1", "title": "t"}]
|
| 124 |
+
assert ops_called is False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def _ok(value):
|
| 128 |
+
return value
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def test_patents_falls_back_to_ops_when_google_patents_is_empty(monkeypatch):
|
| 132 |
+
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
|
| 133 |
+
monkeypatch.setattr(
|
| 134 |
+
services_module, "ops_search", lambda c, q, n: _ok([{"id": "US123", "title": "OPS"}]))
|
| 135 |
+
|
| 136 |
+
result = await make_service(ops_configured=True).patents(
|
| 137 |
+
SerpQuery(queries=["widget"], n_results=5))
|
| 138 |
+
|
| 139 |
+
assert result.results == [{"id": "US123", "title": "OPS"}]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def test_patents_does_not_fall_back_when_ops_is_unconfigured(monkeypatch):
|
| 143 |
+
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
|
| 144 |
+
|
| 145 |
+
result = await make_service(ops_configured=False).patents(
|
| 146 |
+
SerpQuery(queries=["widget"], n_results=5))
|
| 147 |
+
|
| 148 |
+
assert result.results == []
|
| 149 |
+
assert result.error is None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def test_ops_fallback_runs_concurrently_not_one_query_at_a_time(monkeypatch):
|
| 153 |
+
"""This path exists for when Google Patents is unavailable, which is
|
| 154 |
+
exactly when a request can least afford N sequential round-trips to the
|
| 155 |
+
slower backend."""
|
| 156 |
+
import asyncio
|
| 157 |
+
|
| 158 |
+
in_flight = 0
|
| 159 |
+
peak = 0
|
| 160 |
+
|
| 161 |
+
async def slow_ops(client, q, n):
|
| 162 |
+
nonlocal in_flight, peak
|
| 163 |
+
in_flight += 1
|
| 164 |
+
peak = max(peak, in_flight)
|
| 165 |
+
await asyncio.sleep(0.02)
|
| 166 |
+
in_flight -= 1
|
| 167 |
+
return [{"id": q}]
|
| 168 |
+
|
| 169 |
+
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
|
| 170 |
+
monkeypatch.setattr(services_module, "ops_search", slow_ops)
|
| 171 |
+
|
| 172 |
+
await make_service(ops_configured=True).patents(
|
| 173 |
+
SerpQuery(queries=["a", "b", "c", "d"], n_results=5))
|
| 174 |
+
|
| 175 |
+
assert peak > 1, "OPS fallback queries were issued one at a time"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def test_a_failing_ops_fallback_does_not_lose_other_queries_results(monkeypatch):
|
| 179 |
+
async def selective_ops(client, q, n):
|
| 180 |
+
if q == "bad":
|
| 181 |
+
raise RuntimeError("OPS down")
|
| 182 |
+
return [{"id": q}]
|
| 183 |
+
|
| 184 |
+
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
|
| 185 |
+
monkeypatch.setattr(services_module, "ops_search", selective_ops)
|
| 186 |
+
|
| 187 |
+
result = await make_service(ops_configured=True).patents(
|
| 188 |
+
SerpQuery(queries=["good", "bad"], n_results=5))
|
| 189 |
+
|
| 190 |
+
assert result.results == [{"id": "good"}]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# -------------------------------- the fallback chain --------------------------------
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def test_returns_the_first_backend_that_succeeds(monkeypatch):
|
| 197 |
+
calls = []
|
| 198 |
+
|
| 199 |
+
async def fake_ddg(q, n):
|
| 200 |
+
calls.append("ddg")
|
| 201 |
+
return [{"title": "from ddg"}]
|
| 202 |
+
|
| 203 |
+
async def fake_brave(browser, q, n):
|
| 204 |
+
calls.append("brave")
|
| 205 |
+
return [{"title": "from brave"}]
|
| 206 |
+
|
| 207 |
+
monkeypatch.setattr(services_module, "query_ddg_search", fake_ddg)
|
| 208 |
+
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
|
| 209 |
+
|
| 210 |
+
q, results, err = await make_service()._search_one("widget", 5)
|
| 211 |
+
|
| 212 |
+
assert calls == ["ddg"]
|
| 213 |
+
assert results == [{"title": "from ddg"}]
|
| 214 |
+
assert err is None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def test_falls_through_to_the_next_backend_on_failure(monkeypatch):
|
| 218 |
+
calls = []
|
| 219 |
+
|
| 220 |
+
async def failing_ddg(q, n):
|
| 221 |
+
calls.append("ddg")
|
| 222 |
+
raise RuntimeError("ddg blocked")
|
| 223 |
+
|
| 224 |
+
async def fake_brave(browser, q, n):
|
| 225 |
+
calls.append("brave")
|
| 226 |
+
return [{"title": "from brave"}]
|
| 227 |
+
|
| 228 |
+
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
|
| 229 |
+
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
|
| 230 |
+
|
| 231 |
+
q, results, err = await make_service()._search_one("widget", 5)
|
| 232 |
+
|
| 233 |
+
assert calls == ["ddg", "brave"]
|
| 234 |
+
assert results == [{"title": "from brave"}]
|
| 235 |
+
assert err is None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
async def test_reports_an_error_when_every_backend_fails(monkeypatch):
|
| 239 |
+
async def failing(*args):
|
| 240 |
+
raise RuntimeError("blocked")
|
| 241 |
+
|
| 242 |
+
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
|
| 243 |
+
monkeypatch.setattr(services_module, name, failing)
|
| 244 |
+
|
| 245 |
+
q, results, err = await make_service()._search_one("widget", 5)
|
| 246 |
+
|
| 247 |
+
assert results == []
|
| 248 |
+
assert "All backends failed for query 'widget'" in err
|
| 249 |
+
assert "blocked" in err
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ------------------------------ the circuit breaker ------------------------------
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
async def test_skips_a_backend_whose_circuit_is_open(monkeypatch):
|
| 256 |
+
"""A backend that's failed `failure_threshold` times in a row shouldn't
|
| 257 |
+
be attempted again until its cooldown elapses - repeatedly hammering a
|
| 258 |
+
backend that's already rate-limiting us only makes that worse.
|
| 259 |
+
"""
|
| 260 |
+
ddg_calls = []
|
| 261 |
+
|
| 262 |
+
async def failing_ddg(q, n):
|
| 263 |
+
ddg_calls.append(q)
|
| 264 |
+
raise RuntimeError("ddg blocked")
|
| 265 |
+
|
| 266 |
+
async def fake_brave(browser, q, n):
|
| 267 |
+
return [{"title": "from brave"}]
|
| 268 |
+
|
| 269 |
+
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
|
| 270 |
+
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
|
| 271 |
+
|
| 272 |
+
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
|
| 273 |
+
|
| 274 |
+
await service._search_one("first query", 5)
|
| 275 |
+
assert ddg_calls == ["first query"]
|
| 276 |
+
|
| 277 |
+
q, results, err = await service._search_one("second query", 5)
|
| 278 |
+
|
| 279 |
+
assert ddg_calls == ["first query"] # unchanged - DDG was skipped
|
| 280 |
+
assert results == [{"title": "from brave"}]
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
async def test_a_zero_result_query_does_not_open_the_circuit(monkeypatch):
|
| 284 |
+
"""An empty result is ambiguous - it can mean the query genuinely has no
|
| 285 |
+
hits, or that the backend served an anti-bot page that parsed to
|
| 286 |
+
nothing. The breaker is shared across every request, so counting the
|
| 287 |
+
ambiguous case meant a few unusual-but-legitimate queries could disable
|
| 288 |
+
the primary backend for everyone for a full cooldown.
|
| 289 |
+
"""
|
| 290 |
+
ddg_calls = []
|
| 291 |
+
|
| 292 |
+
async def empty_ddg(q, n):
|
| 293 |
+
ddg_calls.append(q)
|
| 294 |
+
raise DuckDuckGoBlockedException()
|
| 295 |
+
|
| 296 |
+
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
|
| 297 |
+
monkeypatch.setattr(
|
| 298 |
+
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
|
| 299 |
+
|
| 300 |
+
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
|
| 301 |
+
|
| 302 |
+
await service._search_one("obscure", 5)
|
| 303 |
+
await service._search_one("another obscure", 5)
|
| 304 |
+
|
| 305 |
+
assert ddg_calls == ["obscure", "another obscure"]
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
async def test_an_empty_result_still_falls_through_to_the_next_backend(monkeypatch):
|
| 309 |
+
"""Not counting it against the backend must not stop the fallback: the
|
| 310 |
+
caller still needs results from somewhere."""
|
| 311 |
+
async def empty_ddg(q, n):
|
| 312 |
+
raise DuckDuckGoBlockedException()
|
| 313 |
+
|
| 314 |
+
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
|
| 315 |
+
monkeypatch.setattr(
|
| 316 |
+
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "from brave"}]))
|
| 317 |
+
|
| 318 |
+
_q, results, err = await make_service()._search_one("widget", 5)
|
| 319 |
+
|
| 320 |
+
assert results == [{"title": "from brave"}]
|
| 321 |
+
assert err is None
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
async def test_a_real_backend_error_still_opens_the_circuit(monkeypatch):
|
| 325 |
+
"""The distinction has to cut both ways: a backend raising a genuine
|
| 326 |
+
error (a rate limit, a transport failure) is still evidence."""
|
| 327 |
+
ddg_calls = []
|
| 328 |
+
|
| 329 |
+
async def failing_ddg(q, n):
|
| 330 |
+
ddg_calls.append(q)
|
| 331 |
+
raise RuntimeError("rate limited")
|
| 332 |
+
|
| 333 |
+
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
|
| 334 |
+
monkeypatch.setattr(
|
| 335 |
+
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
|
| 336 |
+
|
| 337 |
+
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
|
| 338 |
+
|
| 339 |
+
await service._search_one("first", 5)
|
| 340 |
+
await service._search_one("second", 5)
|
| 341 |
+
|
| 342 |
+
assert ddg_calls == ["first"]
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ------------------------------- search aggregation -------------------------------
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
async def test_search_merges_results_from_every_query(monkeypatch):
|
| 349 |
+
monkeypatch.setattr(
|
| 350 |
+
services_module, "query_ddg_search", lambda q, n: _ok([{"title": f"result for {q}"}]))
|
| 351 |
+
|
| 352 |
+
result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
|
| 353 |
+
|
| 354 |
+
assert {r["title"] for r in result.results} == {"result for a", "result for b"}
|
| 355 |
+
assert result.error is None
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
async def test_search_reports_partial_failure_alongside_results(monkeypatch):
|
| 359 |
+
"""A query no backend could answer must be reported even when another
|
| 360 |
+
query succeeded - otherwise the caller silently receives fewer results
|
| 361 |
+
than they asked for with no indication why."""
|
| 362 |
+
async def selective_ddg(q, n):
|
| 363 |
+
if q == "bad":
|
| 364 |
+
raise RuntimeError("blocked")
|
| 365 |
+
return [{"title": f"result for {q}"}]
|
| 366 |
+
|
| 367 |
+
async def failing(*args):
|
| 368 |
+
raise RuntimeError("blocked")
|
| 369 |
+
|
| 370 |
+
monkeypatch.setattr(services_module, "query_ddg_search", selective_ddg)
|
| 371 |
+
monkeypatch.setattr(services_module, "query_brave_search", failing)
|
| 372 |
+
monkeypatch.setattr(services_module, "query_bing_search", failing)
|
| 373 |
+
|
| 374 |
+
result = await make_service().search(SerpQuery(queries=["good", "bad"], n_results=5))
|
| 375 |
+
|
| 376 |
+
assert [r["title"] for r in result.results] == ["result for good"]
|
| 377 |
+
assert "bad" in result.error
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
async def test_search_names_every_failed_query_not_just_the_last(monkeypatch):
|
| 381 |
+
async def failing(*args):
|
| 382 |
+
raise RuntimeError("blocked")
|
| 383 |
+
|
| 384 |
+
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
|
| 385 |
+
monkeypatch.setattr(services_module, name, failing)
|
| 386 |
+
|
| 387 |
+
result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
|
| 388 |
+
|
| 389 |
+
assert result.results == []
|
| 390 |
+
assert "'a'" in result.error and "'b'" in result.error
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The DuckDuckGo backend must not block the event loop.
|
| 2 |
+
|
| 3 |
+
`DDGS.text()` is a synchronous, blocking method. Calling it directly from
|
| 4 |
+
an `async def` freezes the whole process for the duration of the HTTP
|
| 5 |
+
request: no other request progresses, no in-flight Playwright scrape
|
| 6 |
+
progresses, and nothing else on the loop gets a turn.
|
| 7 |
+
|
| 8 |
+
That matters more here than it looks. DuckDuckGo is the *first* backend
|
| 9 |
+
tried for every query in `/serp/search`, which is the flagship MCP tool,
|
| 10 |
+
and both the endpoint docstrings and MCP_INSTRUCTIONS actively encourage
|
| 11 |
+
clients to batch many queries into one call - which under a blocking
|
| 12 |
+
implementation serializes into N sequential stalls instead of N concurrent
|
| 13 |
+
requests.
|
| 14 |
+
|
| 15 |
+
These tests use a blocking stand-in with a known duration rather than the
|
| 16 |
+
real library, so they assert the concurrency property itself and stay fast
|
| 17 |
+
and offline.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import asyncio
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import serp
|
| 24 |
+
from serp import query_ddg_search
|
| 25 |
+
|
| 26 |
+
BLOCK_SECONDS = 0.25
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _BlockingDDGS:
|
| 30 |
+
"""Stands in for the real DDGS: a synchronous call that takes real time."""
|
| 31 |
+
|
| 32 |
+
def text(self, q, max_results=10):
|
| 33 |
+
time.sleep(BLOCK_SECONDS)
|
| 34 |
+
return [{"title": q, "body": "b", "href": "h"}]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def test_concurrent_queries_do_not_serialize(monkeypatch):
|
| 38 |
+
"""Four gathered queries should take about as long as one, not four."""
|
| 39 |
+
monkeypatch.setattr(serp, "DDGS", lambda: _BlockingDDGS())
|
| 40 |
+
|
| 41 |
+
started = time.perf_counter()
|
| 42 |
+
await asyncio.gather(*[query_ddg_search(f"q{i}", 5) for i in range(4)])
|
| 43 |
+
elapsed = time.perf_counter() - started
|
| 44 |
+
|
| 45 |
+
assert elapsed < BLOCK_SECONDS * 2, (
|
| 46 |
+
f"4 concurrent queries took {elapsed:.2f}s; "
|
| 47 |
+
f"serialized would be ~{BLOCK_SECONDS * 4:.2f}s, concurrent ~{BLOCK_SECONDS:.2f}s")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def test_the_event_loop_stays_responsive_during_a_query(monkeypatch):
|
| 51 |
+
"""Anything else scheduled on the loop must keep getting turns while a
|
| 52 |
+
DuckDuckGo query is in flight - this is what a blocked loop costs every
|
| 53 |
+
other request being served at the same time.
|
| 54 |
+
"""
|
| 55 |
+
monkeypatch.setattr(serp, "DDGS", lambda: _BlockingDDGS())
|
| 56 |
+
|
| 57 |
+
ticks = 0
|
| 58 |
+
stop = asyncio.Event()
|
| 59 |
+
|
| 60 |
+
async def heartbeat():
|
| 61 |
+
nonlocal ticks
|
| 62 |
+
while not stop.is_set():
|
| 63 |
+
await asyncio.sleep(0.01)
|
| 64 |
+
ticks += 1
|
| 65 |
+
|
| 66 |
+
beat = asyncio.create_task(heartbeat())
|
| 67 |
+
await query_ddg_search("widget", 5)
|
| 68 |
+
stop.set()
|
| 69 |
+
await beat
|
| 70 |
+
|
| 71 |
+
# A responsive loop ticks ~25 times in 0.25s; a blocked one manages ~1.
|
| 72 |
+
assert ticks > 10, f"event loop only got {ticks} turns during a {BLOCK_SECONDS}s query"
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The glue holding each scraper together.
|
| 2 |
+
|
| 3 |
+
Every Playwright scraper is now: build a URL, open a page, block
|
| 4 |
+
decorative resources, navigate, extract. The builders are tested in
|
| 5 |
+
test_serp_urls.py and the extractors in test_serp_playwright.py; this
|
| 6 |
+
covers the composition - specifically that each scraper navigates to the
|
| 7 |
+
URL its own builder produced.
|
| 8 |
+
|
| 9 |
+
That link is the one the old goto-patching harness could not test, because
|
| 10 |
+
the stand-in it installed accepted the navigation URL and discarded it.
|
| 11 |
+
These use a recording stand-in browser instead: no real Chromium, and the
|
| 12 |
+
URL is the thing being asserted on.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
import serp
|
| 18 |
+
from serp import (bing_search_url, brave_search_url, google_patents_search_url,
|
| 19 |
+
google_scholar_url, playwright_open_page,
|
| 20 |
+
query_bing_search, query_brave_search, query_google_patents,
|
| 21 |
+
query_google_scholar)
|
| 22 |
+
|
| 23 |
+
SENTINEL = [{"title": "extracted"}]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _RecordingPage:
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.goto_urls = []
|
| 29 |
+
self.route_patterns = []
|
| 30 |
+
self.closed = False
|
| 31 |
+
|
| 32 |
+
async def route(self, pattern, handler):
|
| 33 |
+
self.route_patterns.append(pattern)
|
| 34 |
+
|
| 35 |
+
async def goto(self, url, **kwargs):
|
| 36 |
+
self.goto_urls.append(url)
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
async def close(self):
|
| 40 |
+
self.closed = True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class _RecordingContext:
|
| 44 |
+
def __init__(self, page):
|
| 45 |
+
self._page = page
|
| 46 |
+
self.closed = False
|
| 47 |
+
|
| 48 |
+
async def new_page(self):
|
| 49 |
+
return self._page
|
| 50 |
+
|
| 51 |
+
async def close(self):
|
| 52 |
+
self.closed = True
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class _RecordingBrowser:
|
| 56 |
+
def __init__(self):
|
| 57 |
+
self.page = _RecordingPage()
|
| 58 |
+
self.context = _RecordingContext(self.page)
|
| 59 |
+
|
| 60 |
+
async def new_context(self, **kwargs):
|
| 61 |
+
return self.context
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def _sentinel_extractor(page, n_results):
|
| 65 |
+
return SENTINEL
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
SCRAPERS = [
|
| 69 |
+
("query_google_scholar", query_google_scholar,
|
| 70 |
+
"_extract_google_scholar_results", google_scholar_url),
|
| 71 |
+
("query_google_patents", query_google_patents,
|
| 72 |
+
"_extract_google_patents_results", google_patents_search_url),
|
| 73 |
+
("query_brave_search", query_brave_search,
|
| 74 |
+
"_extract_brave_results", brave_search_url),
|
| 75 |
+
("query_bing_search", query_bing_search,
|
| 76 |
+
"_extract_bing_results", bing_search_url),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
|
| 81 |
+
SCRAPERS, ids=[s[0] for s in SCRAPERS])
|
| 82 |
+
async def test_scraper_navigates_to_the_url_its_builder_produced(
|
| 83 |
+
monkeypatch, name, scraper, extractor_attr, builder):
|
| 84 |
+
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
|
| 85 |
+
browser = _RecordingBrowser()
|
| 86 |
+
|
| 87 |
+
await scraper(browser, "agentic ai", 25)
|
| 88 |
+
|
| 89 |
+
assert browser.page.goto_urls == [builder("agentic ai", 25)]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
|
| 93 |
+
SCRAPERS, ids=[s[0] for s in SCRAPERS])
|
| 94 |
+
async def test_scraper_returns_what_its_extractor_produced(
|
| 95 |
+
monkeypatch, name, scraper, extractor_attr, builder):
|
| 96 |
+
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
|
| 97 |
+
|
| 98 |
+
results = await scraper(_RecordingBrowser(), "widgets", 10)
|
| 99 |
+
|
| 100 |
+
assert results == SENTINEL
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
|
| 104 |
+
SCRAPERS, ids=[s[0] for s in SCRAPERS])
|
| 105 |
+
async def test_scraper_blocks_decorative_resources(
|
| 106 |
+
monkeypatch, name, scraper, extractor_attr, builder):
|
| 107 |
+
"""Skipping stylesheets and images meaningfully speeds up navigation,
|
| 108 |
+
and every scraper is supposed to do it."""
|
| 109 |
+
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
|
| 110 |
+
browser = _RecordingBrowser()
|
| 111 |
+
|
| 112 |
+
await scraper(browser, "widgets", 10)
|
| 113 |
+
|
| 114 |
+
assert browser.page.route_patterns == ["**/*"]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@pytest.mark.parametrize("name, scraper, extractor_attr, builder",
|
| 118 |
+
SCRAPERS, ids=[s[0] for s in SCRAPERS])
|
| 119 |
+
async def test_scraper_closes_its_page_and_context(
|
| 120 |
+
monkeypatch, name, scraper, extractor_attr, builder):
|
| 121 |
+
monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
|
| 122 |
+
browser = _RecordingBrowser()
|
| 123 |
+
|
| 124 |
+
await scraper(browser, "widgets", 10)
|
| 125 |
+
|
| 126 |
+
assert browser.page.closed and browser.context.closed
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def test_page_and_context_are_closed_even_when_extraction_raises(monkeypatch):
|
| 130 |
+
async def exploding_extractor(page, n_results):
|
| 131 |
+
raise RuntimeError("selectors changed")
|
| 132 |
+
|
| 133 |
+
monkeypatch.setattr(serp, "_extract_bing_results", exploding_extractor)
|
| 134 |
+
browser = _RecordingBrowser()
|
| 135 |
+
|
| 136 |
+
with pytest.raises(RuntimeError):
|
| 137 |
+
await query_bing_search(browser, "widgets", 10)
|
| 138 |
+
|
| 139 |
+
assert browser.page.closed and browser.context.closed
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def test_context_is_closed_even_when_closing_the_page_raises():
|
| 143 |
+
"""A crashed renderer can make page.close() throw; the context still has
|
| 144 |
+
to be released or it leaks for the process's lifetime."""
|
| 145 |
+
browser = _RecordingBrowser()
|
| 146 |
+
|
| 147 |
+
async def exploding_close():
|
| 148 |
+
raise RuntimeError("renderer gone")
|
| 149 |
+
|
| 150 |
+
browser.page.close = exploding_close
|
| 151 |
+
|
| 152 |
+
with pytest.raises(RuntimeError):
|
| 153 |
+
async with playwright_open_page(browser):
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
assert browser.context.closed
|
|
@@ -1,36 +1,36 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
|
| 12 |
-
from helpers import load_fixture
|
| 13 |
from serp import (
|
| 14 |
BraveSearchBlockedException,
|
| 15 |
BrowserUnavailableError,
|
|
|
|
| 16 |
_extract_bing_results,
|
|
|
|
|
|
|
|
|
|
| 17 |
playwright_open_page,
|
| 18 |
-
query_bing_search,
|
| 19 |
-
query_brave_search,
|
| 20 |
-
query_google_patents,
|
| 21 |
-
query_google_scholar,
|
| 22 |
)
|
| 23 |
|
| 24 |
|
| 25 |
-
async def
|
| 26 |
-
|
| 27 |
-
(extract): `_extract_bing_results` takes a Page that's already loaded,
|
| 28 |
-
so this needs nothing beyond `set_content` - no goto-patching wrapper,
|
| 29 |
-
no browser-as-a-parameter indirection.
|
| 30 |
-
"""
|
| 31 |
-
context = await browser.new_context()
|
| 32 |
-
page = await context.new_page()
|
| 33 |
-
await page.set_content(load_fixture("bing_results.html"))
|
| 34 |
|
| 35 |
results = await _extract_bing_results(page, 10)
|
| 36 |
|
|
@@ -39,24 +39,19 @@ async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
|
|
| 39 |
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 40 |
]
|
| 41 |
|
| 42 |
-
await context.close()
|
| 43 |
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
assert results == [
|
| 51 |
-
{"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
|
| 52 |
-
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 53 |
-
]
|
| 54 |
|
| 55 |
|
| 56 |
-
async def
|
| 57 |
-
|
| 58 |
|
| 59 |
-
results = await
|
| 60 |
|
| 61 |
assert results == [
|
| 62 |
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
|
@@ -64,17 +59,30 @@ async def test_brave_search_parses_results(browser):
|
|
| 64 |
]
|
| 65 |
|
| 66 |
|
| 67 |
-
async def
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
with pytest.raises(BraveSearchBlockedException):
|
| 71 |
-
await
|
| 72 |
|
| 73 |
|
| 74 |
-
async def
|
| 75 |
-
|
| 76 |
|
| 77 |
-
results = await
|
| 78 |
|
| 79 |
assert results == [
|
| 80 |
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
|
@@ -82,10 +90,18 @@ async def test_google_scholar_parses_results(browser):
|
|
| 82 |
]
|
| 83 |
|
| 84 |
|
| 85 |
-
async def
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
assert results == [
|
| 91 |
{
|
|
@@ -103,7 +119,57 @@ async def test_google_patents_parses_results(browser):
|
|
| 103 |
]
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 107 |
with pytest.raises(BrowserUnavailableError):
|
| 108 |
async with playwright_open_page(None):
|
| 109 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the DOM-extraction half of the Playwright-driven scrapers.
|
| 2 |
+
|
| 3 |
+
Each scraper in serp.py is three separable pieces: a URL builder (pure,
|
| 4 |
+
tested in test_serp_urls.py), a navigation step, and an `_extract_*`
|
| 5 |
+
function that reads an already-loaded page. These test the third piece
|
| 6 |
+
against a real headless Chromium - the same engine production uses, so
|
| 7 |
+
selector behaviour is genuinely exercised - with the fixture HTML loaded
|
| 8 |
+
via `set_content`.
|
| 9 |
+
|
| 10 |
+
This used to require a wrapper that replaced `page.goto` on a
|
| 11 |
+
purpose-built fake Browser, because fetch and parse weren't separated.
|
| 12 |
+
That wrapper accepted the navigation URL and discarded it, which is
|
| 13 |
+
precisely why URL bugs were invisible to the suite; splitting the scrapers
|
| 14 |
+
made it unnecessary and it has been deleted.
|
| 15 |
"""
|
| 16 |
|
| 17 |
import pytest
|
| 18 |
|
| 19 |
+
from helpers import load_fixture
|
| 20 |
from serp import (
|
| 21 |
BraveSearchBlockedException,
|
| 22 |
BrowserUnavailableError,
|
| 23 |
+
_block_stylesheet_and_image_resources,
|
| 24 |
_extract_bing_results,
|
| 25 |
+
_extract_brave_results,
|
| 26 |
+
_extract_google_patents_results,
|
| 27 |
+
_extract_google_scholar_results,
|
| 28 |
playwright_open_page,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
)
|
| 30 |
|
| 31 |
|
| 32 |
+
async def test_bing_results_are_extracted(page_factory):
|
| 33 |
+
page = await page_factory(load_fixture("bing_results.html"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
results = await _extract_bing_results(page, 10)
|
| 36 |
|
|
|
|
| 39 |
{"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
|
| 40 |
]
|
| 41 |
|
|
|
|
| 42 |
|
| 43 |
+
async def test_bing_extraction_honours_the_result_limit(page_factory):
|
| 44 |
+
page = await page_factory(load_fixture("bing_results.html"))
|
| 45 |
|
| 46 |
+
results = await _extract_bing_results(page, 1)
|
|
|
|
| 47 |
|
| 48 |
+
assert [r["title"] for r in results] == ["Result A"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
+
async def test_brave_results_are_extracted(page_factory):
|
| 52 |
+
page = await page_factory(load_fixture("brave_results.html"))
|
| 53 |
|
| 54 |
+
results = await _extract_brave_results(page, 10)
|
| 55 |
|
| 56 |
assert results == [
|
| 57 |
{"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
|
|
|
|
| 59 |
]
|
| 60 |
|
| 61 |
|
| 62 |
+
async def test_brave_extraction_honours_the_result_limit(page_factory):
|
| 63 |
+
page = await page_factory(load_fixture("brave_results.html"))
|
| 64 |
+
|
| 65 |
+
results = await _extract_brave_results(page, 1)
|
| 66 |
+
|
| 67 |
+
assert [r["title"] for r in results] == ["Brave Result A"]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def test_brave_block_page_raises(page_factory):
|
| 71 |
+
"""Brave's anti-bot interstitial has no result cards, so it would
|
| 72 |
+
otherwise look like a legitimate zero-result search - and the
|
| 73 |
+
`/serp/search` fallback chain needs an exception to move to the next
|
| 74 |
+
backend.
|
| 75 |
+
"""
|
| 76 |
+
page = await page_factory(load_fixture("brave_blocked.html"))
|
| 77 |
|
| 78 |
with pytest.raises(BraveSearchBlockedException):
|
| 79 |
+
await _extract_brave_results(page, 10)
|
| 80 |
|
| 81 |
|
| 82 |
+
async def test_google_scholar_results_are_extracted(page_factory):
|
| 83 |
+
page = await page_factory(load_fixture("scholar_results.html"))
|
| 84 |
|
| 85 |
+
results = await _extract_google_scholar_results(page, 10)
|
| 86 |
|
| 87 |
assert results == [
|
| 88 |
{"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
|
|
|
|
| 90 |
]
|
| 91 |
|
| 92 |
|
| 93 |
+
async def test_google_scholar_extraction_honours_the_result_limit(page_factory):
|
| 94 |
+
page = await page_factory(load_fixture("scholar_results.html"))
|
| 95 |
+
|
| 96 |
+
results = await _extract_google_scholar_results(page, 1)
|
| 97 |
+
|
| 98 |
+
assert [r["title"] for r in results] == ["Scholar Paper A"]
|
| 99 |
|
| 100 |
+
|
| 101 |
+
async def test_google_patents_results_are_extracted(page_factory):
|
| 102 |
+
page = await page_factory(load_fixture("patents", "search_results.html"))
|
| 103 |
+
|
| 104 |
+
results = await _extract_google_patents_results(page, 10)
|
| 105 |
|
| 106 |
assert results == [
|
| 107 |
{
|
|
|
|
| 119 |
]
|
| 120 |
|
| 121 |
|
| 122 |
+
async def test_google_patents_extraction_honours_the_result_limit(page_factory):
|
| 123 |
+
page = await page_factory(load_fixture("patents", "search_results.html"))
|
| 124 |
+
|
| 125 |
+
results = await _extract_google_patents_results(page, 1)
|
| 126 |
+
|
| 127 |
+
assert [r["id"] for r in results] == ["US11930446B2"]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# --------------------------------- shared plumbing ---------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
async def test_playwright_open_page_raises_when_browser_unavailable():
|
| 134 |
with pytest.raises(BrowserUnavailableError):
|
| 135 |
async with playwright_open_page(None):
|
| 136 |
pass
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class _FakeRoute:
|
| 140 |
+
def __init__(self):
|
| 141 |
+
self.aborted = False
|
| 142 |
+
self.continued = False
|
| 143 |
+
|
| 144 |
+
async def abort(self):
|
| 145 |
+
self.aborted = True
|
| 146 |
+
|
| 147 |
+
async def continue_(self):
|
| 148 |
+
self.continued = True
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class _FakeRequest:
|
| 152 |
+
def __init__(self, resource_type):
|
| 153 |
+
self.resource_type = resource_type
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@pytest.mark.parametrize("resource_type", ["stylesheet", "image"])
|
| 157 |
+
async def test_decorative_resources_are_blocked(resource_type):
|
| 158 |
+
"""Only page text matters to these scrapers, and skipping stylesheets
|
| 159 |
+
and images meaningfully speeds up navigation."""
|
| 160 |
+
route = _FakeRoute()
|
| 161 |
+
|
| 162 |
+
await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
|
| 163 |
+
|
| 164 |
+
assert route.aborted and not route.continued
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@pytest.mark.parametrize("resource_type", ["document", "script", "xhr", "fetch"])
|
| 168 |
+
async def test_everything_else_is_allowed_through(resource_type):
|
| 169 |
+
"""Blocking the main document (or the XHR that renders results) would
|
| 170 |
+
break the scrape entirely."""
|
| 171 |
+
route = _FakeRoute()
|
| 172 |
+
|
| 173 |
+
await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
|
| 174 |
+
|
| 175 |
+
assert route.continued and not route.aborted
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The search URLs each scraper navigates to.
|
| 2 |
+
|
| 3 |
+
These had no coverage at all, and the gap was invisible: the Playwright
|
| 4 |
+
tests drive a real browser but patch `page.goto` with a stand-in that
|
| 5 |
+
accepts the URL and discards it, so everything downstream of navigation
|
| 6 |
+
was verified and everything upstream was not. Mutation testing confirmed
|
| 7 |
+
it - replacing the Bing URL with a constant, dropping `&num=` from the
|
| 8 |
+
Scholar URL, and making the Google Patents URL ignore the query all left
|
| 9 |
+
the full suite green. A scraper that searched for the wrong thing entirely
|
| 10 |
+
passed.
|
| 11 |
+
|
| 12 |
+
These are pure string assertions: no browser, no fixtures, microseconds.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from serp import (
|
| 18 |
+
bing_search_url,
|
| 19 |
+
brave_search_url,
|
| 20 |
+
google_patents_search_url,
|
| 21 |
+
google_scholar_url,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestQueryIsIncluded:
|
| 26 |
+
"""The single most important property: the URL must ask for what the
|
| 27 |
+
caller asked for."""
|
| 28 |
+
|
| 29 |
+
@pytest.mark.parametrize("builder", [
|
| 30 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 31 |
+
def test_the_query_reaches_the_url(self, builder):
|
| 32 |
+
assert "widgets" in builder("widgets", 10)
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("builder", [
|
| 35 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 36 |
+
def test_a_different_query_produces_a_different_url(self, builder):
|
| 37 |
+
assert builder("widgets", 10) != builder("gadgets", 10)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TestQueryIsEncoded:
|
| 41 |
+
"""A raw query in a URL is both a correctness bug (spaces, ampersands
|
| 42 |
+
and hashes truncate or corrupt the search) and the kind of thing that
|
| 43 |
+
turns user input into URL structure."""
|
| 44 |
+
|
| 45 |
+
@pytest.mark.parametrize("builder", [
|
| 46 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 47 |
+
def test_spaces_are_encoded(self, builder):
|
| 48 |
+
url = builder("agentic ai", 10)
|
| 49 |
+
assert "agentic+ai" in url or "agentic%20ai" in url
|
| 50 |
+
assert "agentic ai" not in url
|
| 51 |
+
|
| 52 |
+
@pytest.mark.parametrize("builder", [
|
| 53 |
+
bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
|
| 54 |
+
def test_url_significant_characters_are_escaped(self, builder):
|
| 55 |
+
url = builder("a&b=c#d", 10)
|
| 56 |
+
query_part = url.split("?", 1)[1]
|
| 57 |
+
# Exactly one separator per parameter the builder itself added.
|
| 58 |
+
assert "a&b=c#d" not in query_part
|
| 59 |
+
assert "%26" in query_part
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TestResultCount:
|
| 63 |
+
def test_scholar_asks_for_the_requested_number_of_results(self):
|
| 64 |
+
assert "num=25" in google_scholar_url("widgets", 25)
|
| 65 |
+
|
| 66 |
+
def test_google_patents_asks_for_the_requested_number_of_results(self):
|
| 67 |
+
assert "num=25" in google_patents_search_url("widgets", 25)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TestHosts:
|
| 71 |
+
@pytest.mark.parametrize("builder, host", [
|
| 72 |
+
(bing_search_url, "https://www.bing.com/search"),
|
| 73 |
+
(brave_search_url, "https://search.brave.com/search"),
|
| 74 |
+
(google_patents_search_url, "https://patents.google.com/"),
|
| 75 |
+
(google_scholar_url, "https://scholar.google.com/scholar"),
|
| 76 |
+
])
|
| 77 |
+
def test_points_at_the_expected_endpoint(self, builder, host):
|
| 78 |
+
assert builder("widgets", 10).startswith(host)
|
|
@@ -1,15 +1,13 @@
|
|
| 1 |
import logging
|
| 2 |
|
| 3 |
-
from serp import SerpQuery
|
| 4 |
from utils import log_gathered_exceptions
|
| 5 |
|
| 6 |
|
| 7 |
def test_logs_only_the_exceptions_with_their_query(caplog):
|
| 8 |
-
params = SerpQuery(queries=["q1", "q2", "q3"])
|
| 9 |
results = ["ok result", ValueError("boom"), "another ok"]
|
| 10 |
|
| 11 |
with caplog.at_level(logging.WARNING):
|
| 12 |
-
log_gathered_exceptions(results, "test context",
|
| 13 |
|
| 14 |
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
| 15 |
assert len(warnings) == 1
|
|
@@ -19,11 +17,8 @@ def test_logs_only_the_exceptions_with_their_query(caplog):
|
|
| 19 |
|
| 20 |
|
| 21 |
def test_logs_nothing_when_all_results_succeeded(caplog):
|
| 22 |
-
params = SerpQuery(queries=["q1", "q2"])
|
| 23 |
-
results = ["ok", "also ok"]
|
| 24 |
-
|
| 25 |
with caplog.at_level(logging.WARNING):
|
| 26 |
-
log_gathered_exceptions(
|
| 27 |
|
| 28 |
assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
|
| 29 |
|
|
@@ -33,8 +28,29 @@ def test_uses_its_own_logger_not_the_asyncio_one(caplog):
|
|
| 33 |
misattributed these warnings to the 'asyncio' logging namespace instead
|
| 34 |
of the app's own. Fixed to `logging.getLogger(__name__)`.
|
| 35 |
"""
|
| 36 |
-
params = SerpQuery(queries=["q1"])
|
| 37 |
with caplog.at_level(logging.WARNING):
|
| 38 |
-
log_gathered_exceptions([ValueError("boom")], "ctx",
|
| 39 |
|
| 40 |
assert caplog.records[0].name == "utils"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
|
|
|
|
| 3 |
from utils import log_gathered_exceptions
|
| 4 |
|
| 5 |
|
| 6 |
def test_logs_only_the_exceptions_with_their_query(caplog):
|
|
|
|
| 7 |
results = ["ok result", ValueError("boom"), "another ok"]
|
| 8 |
|
| 9 |
with caplog.at_level(logging.WARNING):
|
| 10 |
+
log_gathered_exceptions(results, "test context", ["q1", "q2", "q3"])
|
| 11 |
|
| 12 |
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
| 13 |
assert len(warnings) == 1
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def test_logs_nothing_when_all_results_succeeded(caplog):
|
|
|
|
|
|
|
|
|
|
| 20 |
with caplog.at_level(logging.WARNING):
|
| 21 |
+
log_gathered_exceptions(["ok", "also ok"], "test context", ["q1", "q2"])
|
| 22 |
|
| 23 |
assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
|
| 24 |
|
|
|
|
| 28 |
misattributed these warnings to the 'asyncio' logging namespace instead
|
| 29 |
of the app's own. Fixed to `logging.getLogger(__name__)`.
|
| 30 |
"""
|
|
|
|
| 31 |
with caplog.at_level(logging.WARNING):
|
| 32 |
+
log_gathered_exceptions([ValueError("boom")], "ctx", ["q1"])
|
| 33 |
|
| 34 |
assert caplog.records[0].name == "utils"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_takes_a_plain_sequence_of_queries(caplog):
|
| 38 |
+
"""This helper is the lowest layer in the codebase; it used to import
|
| 39 |
+
SerpQuery from serp.py purely to read one attribute, inverting the
|
| 40 |
+
dependency. It now accepts any iterable of query strings, so the patent
|
| 41 |
+
and OPS paths can use it too.
|
| 42 |
+
"""
|
| 43 |
+
with caplog.at_level(logging.WARNING):
|
| 44 |
+
log_gathered_exceptions([ValueError("boom")], "ctx", iter(["only-query"]))
|
| 45 |
+
|
| 46 |
+
assert "only-query" in caplog.records[0].message
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_extra_results_without_a_matching_query_are_ignored(caplog):
|
| 50 |
+
"""zip stops at the shorter sequence - pinning that so a mismatch can't
|
| 51 |
+
raise inside an error-logging path.
|
| 52 |
+
"""
|
| 53 |
+
with caplog.at_level(logging.WARNING):
|
| 54 |
+
log_gathered_exceptions([ValueError("a"), ValueError("b")], "ctx", ["q1"])
|
| 55 |
+
|
| 56 |
+
assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
|
|
@@ -1,12 +1,20 @@
|
|
| 1 |
import logging
|
| 2 |
-
|
| 3 |
-
from serp import SerpQuery
|
| 4 |
|
| 5 |
logger = logging.getLogger(__name__)
|
| 6 |
|
| 7 |
|
| 8 |
-
def log_gathered_exceptions(results: list, context: str,
|
| 9 |
-
"""
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
if isinstance(exc, Exception):
|
| 12 |
logger.warning(f"Error during {context} for query '{q}': {exc}")
|
|
|
|
| 1 |
import logging
|
| 2 |
+
from typing import Iterable
|
|
|
|
| 3 |
|
| 4 |
logger = logging.getLogger(__name__)
|
| 5 |
|
| 6 |
|
| 7 |
+
def log_gathered_exceptions(results: list, context: str, queries: Iterable[str]):
|
| 8 |
+
"""Log the exceptions in a `return_exceptions=True` gather, named by the
|
| 9 |
+
query that produced each.
|
| 10 |
+
|
| 11 |
+
Takes the queries themselves rather than a SerpQuery: this is the
|
| 12 |
+
lowest layer in the codebase and importing the domain model inverted
|
| 13 |
+
the dependency for the sake of one attribute access. It also means the
|
| 14 |
+
patent and OPS paths can use it.
|
| 15 |
+
"""
|
| 16 |
+
# strict=False deliberately: this is an error-logging path and must
|
| 17 |
+
# never raise on a length mismatch of its own.
|
| 18 |
+
for exc, q in zip(results, queries, strict=False):
|
| 19 |
if isinstance(exc, Exception):
|
| 20 |
logger.warning(f"Error during {context} for query '{q}': {exc}")
|