feat(licor): LI-COR public-dashboard parser + fetch-licor step - #210
Conversation
Adds measured-data ingestion from the LI-COR cloud public-dashboard timeseries API, for replacing the calculated Kalama Italian-Creek gauge with its real sensor (see docs/PLAN_kalama_licor_gauge.md). Inert until a dataset fetch_url references parser `licor`. The shared `levels fetch` client is GET-only and the LI-COR timeseries endpoint requires a POST with a JSON body, so this follows the established standalone-step pattern (fetch-usgs-ogc / fetch-osmb) rather than bolting a fetch hook onto the async GET path: - `kayak.parsers.licor.LicorParser` (@register("licor")): pure parse_records over the LI-COR JSON; matches channels by **UUID** from the configured fetch_url query params (never the display metricName/metricUnits — the API returns e.g. "°F"); emits tz-aware UTC from epoch-ms; flow/gauge(level)/temperature → DataType; air temperature ignored; future/non-finite points filtered. - `BaseParser.transport` (default "GET"); LicorParser sets "POST". The default `levels fetch` skips non-GET parsers (so it never GETs a POST-only endpoint); `levels fetch-licor` selects only POST parsers. - `levels fetch-licor` (cli/fetch_licor.py): builds + validates the POST body from the configured URL (fails closed before I/O), re-runs the SSRF `_validate_url` guard the GET client uses, POSTs with retry/timeout/ body-cap, and feeds the JSON to LicorParser so storage, caching, and station attribution match every other parser. Soft pipeline step after fetch-usgs-ogc; nothing requires it, so a LI-COR outage never blocks build. Registered in main.py + ensure_all_loaded; wired into the pipeline DAG. Tests: parser records/store + step (build_request validation, POST transport selection, default-fetch skip, fail-closed, end-to-end store, dry-run). Full gate green: ruff, mypy, 1816 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018i8zX4iJDjh6nwLCYRVaRf
…, alerting) Three parallel adversarial reviews (parser, fetch step, tests) found one critical, two major, and several minor issues; all fixed: - C1 (critical, SSRF): requests.post followed redirects by default, so a 3xx from the endpoint to 169.254.169.254/loopback would bypass the host-pin + _validate_url checks (which only see the initial URL). Set allow_redirects=False and treat any 3xx as a failure, matching the GET client (http_client.py). - M1 (major, wrong data): a channel UUID reused across two query params silently mis-typed a series (last param won — water level into flow). build_request now rejects duplicate UUIDs (fail closed); channel_map drops an ambiguous UUID rather than mis-typing it. - M2 (major, alerting): fetch_licor always returned 0, so a permanent config error (malformed dataset URL, non-single-source row) was logged but never surfaced — the soft-step wiring was dead code. Config is now validated in phase 1, errors counted, and the step returns 1 (soft-fail alert) while transient POST failures still return 0. Non-single-source rows are now refused up front instead of silently dropping every obs. - Minors: reject JSON booleans (float(True) is finite → bogus 0/1); bound interval to 1-1440; Content-Length pre-check before reading body. Tests expanded per the test review: real cross-session persistence (no stubbed close, fresh read-back session — proves the commit survives the two-phase lifecycle), the real _validate_url rejection branch, _post retry/give-up/RequestException/Content-Length-cap (time.sleep patched), allow_redirects=False assertion, host-bypass + endpoint-rebuild + window rejection cases, end-to-end air-temp exclusion + fail-closed config, the positive default-fetch skip (GET kept, licor dropped), and a pipeline behavioral test (licor soft-fail alerts but never cascade-skips build). Full gate green: ruff, mypy, 1837 light + 8 slow deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018i8zX4iJDjh6nwLCYRVaRf
The new fetch-licor step ran UNMOCKED in the existing real-pipeline tests, which mock fetch + fetch-usgs-ogc precisely to stay hermetic. fetch-licor then called get_session() and hit "sqlite3.OperationalError: unable to open database file" in CI (it passed locally only because the dev .env supplies a real DB) — flipping test_orphan_check_clean_run_exits_zero from exit 0 to exit 1. Mock kayak.cli.pipeline.fetch_licor.fetch_licor in the six real-pipeline tests, exactly as fetch/fetch-usgs-ogc are already mocked, so no step touches a real DB. No production code change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018i8zX4iJDjh6nwLCYRVaRf
CI fix (
|
|
Adversarial review findings:
Tests run:
|
Adversarial review —
|
|
Correction to my "how it goes live here" bullet above — I misstated the runtime path on first pass and want the deploy guidance to be accurate. The hourly pipeline does not run the editable (all engine units — So the corrected activation path for this PR:
— Claude Opus-4.8(1M)/high |
…G edge Adversarial review findings on #210: - P2: fetch-licor ignored fetch_url.hours. The default GET path hour-gates rows before scheduling; the licor selector walked every active POST row regardless, so a row throttled to e.g. hours:"6,12,18" would POST every pipeline run (defeating dataset throttling, more rate-limit risk on the undocumented endpoint). _prepare now honors fu.hours via the shared _hour_allowed unless --ignore-constraints (new flag, mirrors GET fetch); hour-skipped rows aren't fetched or config-checked. + regression test. - P2: build_request accepted http://. The endpoint is a fixed HTTPS host; http would fail silently (redirects disabled → the http→https 3xx is a transient fetch failure, not a config error, so no soft-fail alert) and plaintext invites tampering. Now https-only. + rejection test. - P2 (latent): fetch-licor had no DAG edge to update-gauge-cache, so a future requires-ordered executor could cache its readings a run late. update-gauge-cache now requires ("calc-rating","fetch-licor"). Safe for the "licor never blocks build" goal: outages are soft_failed and _should_skip cascades only on failed/skipped. DAG test updated. - Nit: reworded the Content-Length comment (it bounds parsing, not transfer — requests buffers the body; fine for a few-KB pinned host). Deferred (documented on the PR): --skip-fetch keeping fetch-licor is intentional/consistent with fetch-usgs-ogc; hourly alerts on a permanent misconfig are by design; source 363 timezone=NULL is a kayak_data-PR note. Full gate green: ruff, mypy, 1839 light + 8 slow deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018i8zX4iJDjh6nwLCYRVaRf
Addressed the review findings —
|
|
Follow-up adversarial review at The earlier findings are fixed: Remaining low-risk follow-ups:
Tests run:
|
Adversarial re-review — head
|
…G nits Follow-up adversarial re-review of #210 (all prior P2s confirmed fixed): - P3: the POST sent no User-Agent (just Accept), so LI-COR saw python-requests/… instead of the configured pipeline UA. Add FETCH_USER_AGENT to the POST headers — courteous + identifiable for an undocumented third-party endpoint we want to keep access to. Asserted in test_fetch_one_posts_with_redirects_disabled. - P3: documented that fetch-licor has no batch wall-clock budget (the --budget/async_fetch_many machinery is GET-batch-specific); a hung endpoint is bounded by the per-request FETCH_TIMEOUT, consistent with the sibling fetch-usgs-ogc step. No behavior change. - Test nit: test_prepare_honors_hour_constraint computed excluded hours from one now() while _hour_allowed read the clock again — could flake across a UTC hour rollover. Now monkeypatches _hour_allowed → deterministic. - DAG-test nit: the "nothing requires fetch-licor" comment went stale when update-gauge-cache gained the edge; reworded. Updated the soft-fail test's stale docstring + assert update-gauge-cache runs on soft-fail. - Added test_pipeline_licor_hard_failure_cascade_skips_cache: locks the new edge's contract — a fetch-licor *raise* (vs soft rc=1) cascade-skips update-gauge-cache + build (the actual behavioral change the edge introduces). Full gate green: ruff, mypy, 1840 light + 8 slow deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018i8zX4iJDjh6nwLCYRVaRf
Addressed the re-review P3s + nits —
|
Why
Engine groundwork for replacing the calculated Kalama Italian-Creek gauge (source 354) with its real LI-COR sensor — the implementation half of
docs/PLAN_kalama_licor_gauge.md. Inert until akayak_datafetch_urlreferences parserlicor(no dataset change here).What
The shared
levels fetchclient is GET-only and the LI-COR timeseries endpoint requires a POST with a JSON body, so this follows the established standalone-step pattern (fetch-usgs-ogc,fetch-osmb) rather than bolting a fetch hook onto the async GET path:kayak.parsers.licor.LicorParser(@register("licor")): pureparse_recordsover the LI-COR JSON. Matches channels by UUID from the configuredfetch_urlquery params — never the displaymetricName/metricUnits(the API returns e.g."°F"). Emits tz-aware UTC from epoch-ms; flow/water-level/water-temp →DataType.{flow,gauge,temperature}; air temperature ignored; future / non-finite / boolean / malformed points filtered.BaseParser.transport(default"GET");LicorParsersets"POST". The defaultfetchskips non-GET parsers (so it never GETs a POST-only endpoint);fetch-licorselects only POST parsers.levels fetch-licor(cli/fetch_licor.py): validates + builds the POST body from the configured URL (fail closed before any I/O), re-runs the_validate_urlSSRF guard, POSTs withallow_redirects=False+ retry + timeout + body cap, and feeds the JSON toLicorParserso storage, caching, and station attribution match every other parser. Wired as a soft pipeline step afterfetch-usgs-ogc; nothingrequiresit, so a LI-COR outage can never blockbuild.Registered in
main.py+ensure_all_loaded; pipeline DAG + CLAUDE.md updated.Adversarial review (3 parallel reviewers) — findings fixed in
be00bf7requests.postfollowed redirects by default → a 3xx to169.254.169.254/loopback would bypass the host-pin +_validate_url(they only see the initial URL). Nowallow_redirects=False, 3xx treated as failure.build_requestrejects duplicate UUIDs;channel_mapdrops ambiguous ones rather than mis-typing.interval1–1440; Content-Length pre-check.Tests (37 new)
Parser records/store;
build_requesthappy-path + host-bypass/endpoint-rebuild/window rejection; transport selection; default-fetch skip (GET kept, licor dropped);_validate_urlrejection branch;_postretry/give-up/RequestException/Content-Length cap (time.sleeppatched);allow_redirects=Falseassertion; real cross-session persistence (no stubbedclose, fresh read-back session); end-to-end air-temp exclusion + fail-closed config (returns 1, no POST); pipeline behavioral test (licor soft-fail alerts but never cascade-skips build).Local gate green: ruff, mypy, 1837 light + 8 slow deploy tests.
Follow-up (separate, per the plan)
The
kayak_datadataset change (add thelicorfetch_url+ source 363, retire source 354 + calc 21) is a separate PR and requires bumpingengine_test_refto this engine commit first (thedataset_ci_pin_first_bootstrapordering). Not included here.— Claude · Opus-4.8(1M) · x-high
🤖 Generated with Claude Code