Initial commit
This commit is contained in:
commit
5f2db08b43
100 files changed
+16985
No files matched your search
+236
@@ -0,0 +1,236 @@
|
||||
# QA Sign-Off Checklist — California Legislature Daily File Scraper
|
||||
|
||||
**Task:** `t_8e5a3821` · **Lane:** Software Quality Assurance
|
||||
**Status:** _DRAFT — awaiting sign-off before first production cron run_
|
||||
**Scope:** ingestion (`scraper.ingest`) + parsing (`scraper.parsers`) pipeline,
|
||||
cache, HTTP/retry layer, and the `python -m scraper` CLI.
|
||||
|
||||
This checklist is the gate the acceptance criteria require ("the QA checklist is
|
||||
reviewed and signed off before the first production cron run"). Every item is
|
||||
either (a) verified by the automated suite in `tests/`, or (b) a known failure
|
||||
mode with a mitigation. **Do not start the production cron until every "Pre-cron"
|
||||
box is ticked and a reviewer has signed the footer.**
|
||||
|
||||
---
|
||||
|
||||
## 0. How to use this checklist
|
||||
|
||||
```
|
||||
# Full deterministic suite (no network) + coverage gate on the parser core:
|
||||
python3 -m pytest
|
||||
|
||||
# Emit the coverage report the acceptance criteria require (>=85% on parser core):
|
||||
python3 -m pytest --cov
|
||||
|
||||
# Opt in to the one live smoke test (skipped by default so CI never depends on
|
||||
# the external servers being up):
|
||||
CALI_SCRAPER_SMOKE=1 python3 -m pytest tests/test_live_smoke.py -m smoke
|
||||
```
|
||||
|
||||
Green criteria (from the task):
|
||||
- [ ] `pytest` passes green on the fixture suite (all of `tests/`, no skips).
|
||||
- [ ] Coverage report meets the **>= 85 %** threshold on `scraper.parsers`
|
||||
(+ `scraper.bill_id`); `--cov` exits 0.
|
||||
- [ ] QA checklist reviewed and signed off (this document) before cron.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated gate (must be green)
|
||||
|
||||
| # | Check | Verified by | Passes? |
|
||||
|---|-------|-------------|:---:|
|
||||
| 1.1 | Parser codec round-trips AB/SB/ACR/SCR/HR/SJR/ABR + rejects junk | `tests/test_bill_id.py` (21) | ☐ |
|
||||
| 1.2 | Schema/JSONL: field set, content-hash id, null-stripping, round-trip | `tests/test_schema.py` (17) | ☐ |
|
||||
| 1.3 | Parsers: 3 dates, both chambers, floor/committee/hearing discrimination | `tests/test_parsers.py` (35) | ☐ |
|
||||
| 1.4 | Malformed rows skip + log warning, batch survives; UTF-16/encoding edge | `tests/test_parsers.py` | ☐ |
|
||||
| 1.5 | Ingest→parse end-to-end → valid JSONL schema; request-log recorded | `tests/test_integration.py` | ☐ |
|
||||
| 1.6 | Failure modes: expected-404, antibot-403, transient-retry×3 | `tests/test_integration.py` | ☐ |
|
||||
| 1.7 | Cache hit serves from disk with **zero** network; corrupt cache = miss | `tests/test_integration.py` | ☐ |
|
||||
| 1.8 | Live smoke (opt-in); **skipped by default** | `tests/test_live_smoke.py` | ☐ (skip) |
|
||||
| 1.9 | Coverage ≥ 85 % on `scraper.parsers` / `scraper.bill_id` | `pytest --cov` | ☐ |
|
||||
|
||||
**Known, deliberately-pinned behaviour (not bugs — see §4):**
|
||||
- `normalise_date("2026-08-31")` returns `None` (the `-NNN` display-suffix
|
||||
stripper runs before ISO); the site only ever emits `MM/DD/YY`, so this is
|
||||
safe in practice. A fix must surface as a changed assertion, not a silent
|
||||
regression.
|
||||
|
||||
---
|
||||
|
||||
## 2. Failure modes, detection & mitigation
|
||||
|
||||
Each row maps a known failure mode to how the system detects it, how the
|
||||
automated suite proves the handling, and the operator mitigation. These are the
|
||||
"site redesign, rate-limiting, encoding shifts" the task calls out.
|
||||
|
||||
### 2.1 Site redesign / structural HTML change · _highest risk_
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | A CSS class or `data-sort` attribute the parser relies on (e.g. `tr.floor-bill-details`, `td.floor_bill-measure a`) is renamed/removed. |
|
||||
| **Detection (automatic)** | `tests/test_parsers.py::TestArchivedSample` parses the archived `samples/assembly_dailyfile_latest.html` and asserts **≥150 bill rows / ≥4 hearing rows**. A structural break drops the count → **red gate**. Also: `test_happy_path_ingest_then_parse_then_jsonl` asserts `AB 28` and `SB 795` survive the full pipeline. |
|
||||
| **Runtime symptom** | Parse yields 0 (or a few) records where the prior run yielded ~150. |
|
||||
| **Mitigation** | Roll back to last good `cache/raw/<date>` (never deleted). Re-scrape selectors from the live page, update `DAILY_FILE_PARSE_SPEC.md §3` and the parser selectors, re-record a fresh sample in `samples/`, re-run the gate. |
|
||||
| **Watch** | A sudden count drop is the #1 leading indicator — pair the cron with a "record-count anomaly" alert (count vs. 7-day median). |
|
||||
|
||||
### 2.2 Rate-limiting / 429
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | `httpx` 429 (or 5xx) — the source throttles on volume. |
|
||||
| **Detection (automatic)** | `tests/test_integration.py::test_transient_retries_three_times` proves the retry loop makes 3 attempts with exponential back-off `[1.0, 2.0]`s and then raises `TransientError`. |
|
||||
| **Runtime handling** | `config.yaml → http.retry_status` drives this; 429 should be added to that list if the source uses 429 (currently `[500,502,503,504]`). Backoff is capped at `backoff_cap_seconds`. |
|
||||
| **Mitigation** | Lower cron frequency; add 429 to `retry_status` with a longer cap; if sustained, route via the `blocked-page-recovery` skill. Alert is fired per attempt via the request log. |
|
||||
| **Watch** | A rising share of `5xx_retrying`/`transient_gave_up` outcomes in `cache/request_log.jsonl`. |
|
||||
|
||||
### 2.3 Antibot / 403 (Senate PDF path) · _documented, expected_
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | `senate.ca.gov/dailyfile/publications/download/...` returns a 67-byte 403 antibot challenge on programmatic fetch. |
|
||||
| **Detection (automatic)** | `test_antibot_403_is_flagged` proves a 403 → `status="antibot"`, `all_ok=False`, alert fired, **no retry-storm**. |
|
||||
| **Runtime handling** | 403 is classified `antibot_status=[403]`; it is NOT retried (a retry storm just wastes the throttle) — it alerts and stops. Mitigation is the `blocked-page-recovery` skill (headless browser + session pre-warm). |
|
||||
| **Mitigation / workaround** | Until antibot is solved, **Senate data is unavailable**; Assembly-only output is valid. Optionally enrich Senate floor/committee items from the leginfo `billResultsClient`/`billHistoryClient` alt (spec §5b). |
|
||||
| **Watch** | `antibot` appears in request-log outcomes only on the Senate source. |
|
||||
|
||||
### 2.4 "No publication today" / expected 404 · _expected, not a failure_
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | Senate PDF path 404s on non-legislative days (weekends, recess). |
|
||||
| **Detection (automatic)** | `test_expected_404_is_not_an_error` proves 404 on a `missing_is_expected_404` source → `status="not_found"` with `"expected"` in the note, and `all_ok` is **not** polluted by it. |
|
||||
| **Runtime handling** | `config.yaml → senate_daily_file.missing_is_expected_404: true` makes a 404 a clean exit-0 outcome; cron mail fires **only on real failures** (antibot/transient/unexpected-404). |
|
||||
| **Watch** | A 404 on the *Assembly* source (which has no `missing_is_expected_404`) is a warning-level alert, not a clean no-file. |
|
||||
|
||||
### 2.5 Encoding shifts / bad charset
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | A page changes its declared charset (UTF-8 → Windows-1252, adds a BOM, etc.) and produces mojibake or crashes a naive decode. |
|
||||
| **Detection (automatic)** | `tests/test_parsers.py::test_utf16_edge_case_does_not_crash` writes a UTF-16/BOM file and asserts the parser **survives** (logs an encoding warning, returns `[]`) rather than aborting the batch. |
|
||||
| **Runtime handling** | `scraper.parsers._read_text` sniffs the charset from `<meta>`/BOM and falls back to `utf-8/replace`, logging a warning on replacement chars. |
|
||||
| **Mitigation** | On persistent mojibake the field values are corrupted but the pipeline does not crash; inspect the warning in logs and re-fetch. |
|
||||
| **Watch** | `replacement chars detected` warnings in the request/app log. |
|
||||
|
||||
### 2.6 Corrupt / truncated cache
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | A crash mid-download leaves a truncated raw file, or a meta sha256 mismatches the bytes. |
|
||||
| **Detection (automatic)** | `tests/test_integration.py::test_cache_corruption_is_treated_as_a_miss` proves a sha mismatch is treated as a **cache miss**, so a good fetch re-runs instead of serving poison. |
|
||||
| **Runtime handling** | `RawCache.put` is atomic (tmp + `os.replace`); `get` verifies sha256 before serving. |
|
||||
| **Watch** | Repeated misses on a date that was previously cached suggests disk corruption — inspect `cache/raw`. |
|
||||
|
||||
### 2.7 Malformed rows / bad data within an otherwise-valid page
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | A floor row has no bill link, a non-numeric item cell, or a hearing row with neither committee nor date. |
|
||||
| **Detection (automatic)** | `test_malformed_rows_skipped_not_aborted` proves bad rows are skipped **per-row** with a `WARNING` and the batch (good + recoverable rows) still completes. |
|
||||
| **Runtime handling** | `scraper.parsers` skips+warns, never aborts; bad rows are logged for triage. |
|
||||
| **Watch** | A spike in per-row WARNING lines indicates an upstream data-quality regression. |
|
||||
|
||||
### 2.8 Dependency / import availability
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | `httpx`/`PyYAML`/`beautifulsoup4`/`lxml` missing in a fresh cron env. |
|
||||
| **Detection** | Import guards in `scraper/config.py` and `scraper/http_client.py` raise a `SystemExit` with a clear "install with `pip install …`" message (not a bare `ModuleNotFoundError`). |
|
||||
| **Mitigation** | `requirements.txt` pins the working versions (Python 3.9). The cron wrapper should `pip install -r requirements.txt` (or run in a venv with them pre-installed) before each run. |
|
||||
| **Watch** | A run that exits 2 with the "… is required" message means the cron env is missing a dep. |
|
||||
|
||||
### 2.9 P2 finding: retry-storm on non-retryable 4xx · _defect, regression-guarded_
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Symptom** | `scraper/http_client.py` has a catch-all `return "transient"` for any non-404/non-antibot HTTP status. A permanent 4xx such as 400 is retry-stormed 3× with back-off `[1.0, 2.0]s` — identical to a transient 5xx, even though the code's own comment says it "should not retry-storm." |
|
||||
| **Detection (automatic)** | `tests/test_integration.py::TestRetryStormRegressionGuard::test_retry_storm_on_4xx_is_currently_3_attempts` asserts the *current buggy* behaviour (3 attempts, `sleeps == [1.0, 2.0]`), keeping the suite green. The companion `test_non_retryable_4xx_must_give_up_on_attempt_1` is a **strict `xfail`** that will flip to a failure when the engine is fixed, signalling "finding resolved, remove/convert this guard." |
|
||||
| **Runtime impact** | A 400 from the source causes 3 full fetches + 3 request-log entries where 1 would suffice. Not a data-corruption risk; a waste of the request throttle and a noise amplifier in the request log. |
|
||||
| **Fix** | In `scraper/http_client.py`, narrow the catch-all so that only transient-network faults and the explicit `retry_status` list are classified `transient`; a 4xx that is not 404/antibot/`retry_status` should be classified a non-retryable failure (a single-attempt terminal state), or `retry_status` should be expanded to exclude it. |
|
||||
| **Watch** | This xfail is a trip-wire; a strict-xfail going green is a signal to convert it to a plain test and re-baseline the counts. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Rollback & recovery procedure
|
||||
|
||||
The pipeline is designed so that **a bad run never destroys a good one** — the
|
||||
cache is append-only on the network side and atomic on the disk side.
|
||||
|
||||
### 3.1 Pre-cron pre-flight (run every time, before first cron)
|
||||
- [ ] `.git` working copy clean or on the expected branch/commit.
|
||||
- [ ] `python3 -m pytest` is green (all of `tests/`, no non-live skips failing).
|
||||
- [ ] `python3 -m pytest --cov` reports **≥ 85 %** on `scraper.parsers` and
|
||||
exits 0 (the `--cov` addopts enforce this; a sub-floor run is a failure).
|
||||
- [ ] `python3 -m scraper list` shows both chamber sources with the URLs from
|
||||
`config.yaml`.
|
||||
- [ ] `python3 -m scraper check --date <recent-legislative-day>` shows the
|
||||
cache populated (or a first run is expected to be a miss).
|
||||
- [ ] `cache/request_log.jsonl` writable; alert sink configured (`config.yaml`).
|
||||
- [ ] This checklist is signed (footer) by a human reviewer.
|
||||
|
||||
### 3.2 Rollback on a bad run
|
||||
1. **Do not delete the cache.** `cache/raw/<source>/<date>.<ext>` is the source
|
||||
of truth and is append-only; a failed new fetch does not overwrite a prior
|
||||
good cache hit.
|
||||
2. To serve "last known good": re-ingest with the default `force=False` — the
|
||||
cache serves the last good bytes; only a `--force` re-fetch reaches the
|
||||
network.
|
||||
3. Re-parse the cached bytes with `python -m scraper parse --input cache/raw/…`
|
||||
— the parse is a pure function of the bytes, so re-parsing is safe.
|
||||
4. If the parser selectors broke (red gate, §2.1): roll back the parser commit,
|
||||
re-parse the *previous* cached bytes with the old code, and hold the new
|
||||
sample + selector changes until the gate is green again.
|
||||
5. If a corrupt cache is suspected (§2.6): delete only the affected
|
||||
`cache/raw/<source>/<date>.*` + its `.meta.json` sidecar; the next non-
|
||||
forced run re-fetches. (A sha-mismatch already forces a re-fetch
|
||||
automatically, so this is belt-and-braces.)
|
||||
6. Record the incident in `cache/request_log.jsonl` review and, if it changed
|
||||
upstream, refresh the archived sample in `samples/` and the parse spec.
|
||||
|
||||
### 3.3 Recovery from each failure mode
|
||||
| Mode | Recovery |
|
||||
|---|---|
|
||||
| 2.1 redesign | Roll back parser commit; re-derive selectors from live page; new sample; re-gate. |
|
||||
| 2.2 rate-limit | Back off cron frequency; add 429 to `retry_status`; or `blocked-page-recovery` skill. |
|
||||
| 2.3 antibot (Senate) | Use `blocked-page-recovery` skill (headless + session pre-warm); meanwhile run Assembly-only. |
|
||||
| 2.4 expected 404 | No action — this is normal; confirm it's `not_found` + "expected". |
|
||||
| 2.5 encoding | Re-fetch; confirm charset in the warning; update `_read_text` sniff list if a new charset appears. |
|
||||
| 2.6 corrupt cache | Delete affected cache entry (+meta); re-fetch. |
|
||||
| 2.7 malformed rows | Triage the logged `WARNING`s; an upstream data regression, not a pipeline bug. |
|
||||
| 2.8 missing dep | `pip install -r requirements.txt` in the cron env. |
|
||||
| 2.9 retry-storm(4xx) | Engineer narrows the transient catch-all in `http_client.py`; re-baseline the xfail guard (§2.9). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Test suite map (what lives where)
|
||||
|
||||
| File | Covers | Count |
|
||||
|---|---|---|
|
||||
| `tests/test_bill_id.py` | `scraper.bill_id` codec: parse/normalise/origin/display/supplement + negatives | 21 |
|
||||
| `tests/test_schema.py` | `scraper.schema` BillAction: fields, content-hash id, JSONL round-trip | 17 |
|
||||
| `tests/test_parsers.py` | `scraper.parsers`: sample + 3 dates + both chambers + robustness/edge | 35 |
|
||||
| `tests/test_integration.py` | ingest→parse end-to-end JSONL schema + 404/antibot/transient/cache + P2 retry-storm guard | 12 |
|
||||
| `tests/test_live_smoke.py` | **opt-in** live-URL smoke (skipped unless `CALI_SCRAPER_SMOKE=1`) | 2 |
|
||||
|
||||
Deterministic suite (no network) total: **84 passed** (+ 1 strict `xfail`, a
|
||||
regression trip-wire on the §2.9 retry-storm finding). Live smoke: 2, skipped by
|
||||
default so CI is independent of external availability. Full run is `84 passed,
|
||||
2 skipped, 1 xfailed`; coverage on the parser core (`scraper.parsers` +
|
||||
`scraper.bill_id`) is **92.0 %**, which clears the ≥ 85 % `fail_under` gate.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sign-off
|
||||
|
||||
**Gate:** no production cron run starts until 1.1–1.9 are green and the §3.1
|
||||
pre-flight list is ticked.
|
||||
|
||||
| Role | Name | Date | Signature |
|
||||
|---|---|---|---|
|
||||
| QA (author) | software_quality_assurance | ______ | ____________ |
|
||||
| Engineer (parser lane) | __________ | ______ | ____________ |
|
||||
| Manager / release | __________ | ______ | ____________ |
|
||||
|
||||
_Reviewer note: this checklist is a DRAFT pending human sign-off. The automated
|
||||
half (1.1–1.9) is enforced by `pytest`/`pytest --cov` and is not a substitute
|
||||
for a human ticking §3.1._
|
||||
Reference in new issue
Block a user