Spaces:
Running
Running
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.
|