File size: 9,695 Bytes
5a8a721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9841346
5a8a721
 
 
73c1aa6
5a8a721
 
 
 
 
 
f493ed5
 
 
 
 
 
 
 
 
5a8a721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e44fdef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a8a721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9841346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a8a721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9841346
 
 
 
 
 
 
5a8a721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# Contributing to SERPent

This file describes how this codebase is developed, not just how to run it.
It exists because the project went from zero tests to a real safety net in
one focused pass, and the point of that work is lost if the next change
skips the same discipline. It ships as a normal file in this repo, which
means it also lands on the Hugging Face Space on every deploy (see
"Deployment" below) β€” it isn't GitHub-only documentation.

## Setup

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
playwright install chromium   # only if not already present on the machine
```

Dependencies in `requirements.txt` / `requirements-dev.txt` are pinned to
specific tested ranges, not left bare β€” see the comment at the top of
`requirements.txt` for the policy and how to bump one. This matters more
than it looks: coming back to this project after any gap in time, a bare
`pip install` with no pins would silently grab whatever's newest on PyPI
that day, and you'd have no way to tell "my change broke this" from "a
dependency update broke this."

## Running tests

```bash
ruff check .   # same lint CI runs
pytest
```

271 tests, a few seconds, fully offline. All outbound HTTP is mocked with
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
Google Patents search) run against a real headless Chromium but navigate to
local fixture HTML instead of the live sites β€” see `tests/helpers.py` for
how and why. Nothing here depends on, or can trip, any external site's
anti-bot measures.

A note on the two backends that are blocked from the deployed Space's IP
at the time of writing: Google Scholar serves an anti-bot interstitial
rather than results, and `query_google_scholar` now detects that and raises
`GoogleScholarBlockedException` instead of waiting out a 30s selector
timeout. Bing answers normally but wraps every result link in its `/ck/a`
redirector, which `decode_bing_redirect` unwraps. Both were found by
running the live smoke test below against the deployed MCP server, not by
the suite - which is exactly the division of labour described next.

**Important limitation to keep in mind:** this suite protects against
regressions in this code. It cannot catch a live site changing its markup,
because the fixtures are frozen snapshots of the selector contract as of
when they were written β€” they'll stay green forever even if Bing's HTML
changes tomorrow. After any real gap in development, or if a scraper starts
returning empty/wrong results in production, run a live smoke test against
the deployed MCP server or REST API before assuming the code is at fault:

```
search(["some query"])
scrap_patent("US10000000B2")   # or any real, known-good patent id
```

If a backend's selectors have drifted, that's a normal, expected kind of
bug here β€” fix it the same way as anything else (see below): update the
fixture HTML to match the new real markup, confirm the test fails against
the current selectors, fix the selectors, confirm green.

## How the code is laid out

- **`app.py`** is HTTP wiring only: request validation, dependency
  providers, the lifespan, and mapping domain errors onto status codes.
- **`services.py`** holds the orchestration policy - which backend to try,
  in what order, when to fall back, what counts as evidence a backend is
  unhealthy, and when a patent is genuinely absent rather than merely
  unreachable. None of that is HTTP-specific, and keeping it out of route
  handlers is why most of its tests need no ASGI client.
- **`serp.py` / `scrap.py` / `ops.py`** are the backends. Each scraper is
  split into a pure URL builder, a navigation step, and an `_extract_*`
  function taking an already-loaded page, so all three can be tested
  separately.
- **`utils.py` / `circuit_breaker.py`** are dependency-free helpers.

The services take their stateful collaborators (HTTP client, browser,
circuit breaker, OPS credentials) as constructor arguments. That is
deliberate and worth preserving: it is what lets a test build a service
with its own circuit breaker instead of resetting a process-wide singleton
between tests, and hand in a stand-in for the OPS credentials instead of
assigning to private attributes on the real token manager. `app.py`'s
`get_search_service` / `get_patent_service` providers exist so
`app.dependency_overrides` can replace them wholesale in a test.

The stateless backend functions stay module-level imports in
`services.py`; monkeypatching one of those in a test is fine, because they
hold no state to leak between tests.

## The development loop (TDD, not test-after)

Every change in this codebase's history followed the same loop, and new
changes should too:

1. **Write the test first.** For new behavior, write it against the
   behavior you want and confirm it fails (red) for the right reason - not
   a typo, an import error, or a fixture bug.
2. **For a refactor** (behavior should stay identical), write
   characterization tests for the *current* behavior first if none exist,
   confirm they pass against the unchanged code, and only then refactor
   under that safety net. `app.py` had zero tests before its endpoint
   deduplication; the tests were added and confirmed green against the old
   code before a single line of the refactor happened.
3. **Implement the minimal change** to make the new/updated test pass.
4. **Run the full suite**, not just the new test - a shared helper's bug
   should show up as multiple failures, and that's a sign it's finding real
   things, not a nuisance.
5. **Distrust a test that never fails.** When a test's value is in
   question, deliberately break the code it's supposed to protect and
   confirm the test actually catches it, then revert. This caught a real
   gap once already: a circuit breaker test that looked correct was
   actually vacuous until this step exposed it.

   Applying this systematically is worth a pass of its own, and one such
   pass is where most of the tests added since came from. Line coverage
   was 93% and hid the problem completely: the suite killed five of six
   mutants aimed at pure functions, and one of seven aimed at the I/O
   boundary. Deleting the entire API-key middleware left it green, as did
   replacing the Bing search URL with a constant. The lesson worth keeping
   is *where* to point this technique - at the code that touches the
   outside world, not the code that's easy to test.

   A useful heuristic fell out of it: if a property has no behavioural
   signature at all, no black-box test can pin it. `==` and
   `secrets.compare_digest` return the same answer for every input and
   differ only in timing, so that one test asserts structurally that the
   comparison goes through `compare_digest`. Reach for that only when
   there is genuinely nothing observable to assert on.
6. **Watch for shared mutable state across tests.** A few characterization
   tests in this codebase turned out to be recording real state into a
   module-level singleton (a circuit breaker) with nothing resetting it
   between tests - harmless by luck at the time, but the kind of thing that
   silently breaks an unrelated, later test once one more test is added.
   Give shared singletons a fresh instance per test (an autouse fixture,
   or explicit monkeypatching) rather than relying on the real one.
7. **Validate risky changes for real, not just by review.** The one Docker
   change in this project's history that could plausibly have broken the
   live deployment (running the container as non-root, which changes
   whether Chromium's sandbox can start at all) was verified by actually
   building and running the image locally and confirming a real scrape
   request completed - not by reasoning about it from the Dockerfile alone.

## CI / deployment

- **`.github/workflows/test.yml`** runs `ruff check .` and then the full
  suite on every pull request against `main`, so a change gets feedback
  before merge. The lint rule set (see `pyproject.toml`) is deliberately
  small - pyflakes-equivalent checks plus bugbear - because the first pass
  over this repo found a duplicate import shadowing another, four unused
  imports and an f-string with no placeholders. Widen it when the team
  wants more rather than starting broad and accumulating ignores.
- **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
  (`test` job) on every push to `main`, and only then (`needs: test`)
  pushes the repo to the Hugging Face Space (`deploy` job) with
  `git push --force-with-lease`. That flag matters: a plain `--force` push
  is a blind overwrite with no way to tell if someone else pushed to the
  same Space in the meantime; `--force-with-lease` fails loudly instead of
  silently clobbering a concurrent change. If you ever see that step fail
  with "stale info," it means exactly that - fetch, look at what's there,
  and reconcile before retrying.
- Because the deploy step pushes the whole repo tree (`HEAD:main`), not a
  curated subset, everything here - including this file, the tests, and
  the CI config - ends up on the Space too. There's nothing GitHub-only
  about this process.

## Secrets to know about

These expire or rotate silently and are the most likely source of a
deploy failure that has nothing to do with the code:

- `HF_TOKEN` (repo secret) - used to push to the Hugging Face Space.
- `OPS_CONSUMER_KEY` / `OPS_CONSUMER_SECRET` (optional, set on the Space) -
  EPO OPS API credentials for the patent-search fallback.
- `SERPENT_API_KEY` (optional, set on the Space) - if set, gates every
  request behind an API key; if you set this and then can't reach the
  deployed API, check here first.