forge-climada-api-archive — CLIMADA Data API download-and-archive script¶
forge mini-spec, 2026-08-10. Trivial-case lane: one issue →
/build. This doc is the issue body for the single work item below.
Resume state (paused 2026-08-10)¶
Run paused mid-forge by the owner. Where things stand and how to continue:
- Origin:
/forge --worktree --ghostrun replacing the empty../climada-datasetsside project (conda env only, zero scripts — stays untouched) with an in-repo download-and-archive script. Validation verdict: reasonable, with the design correction that the CLIMADA Data API is plain REST (litpop_exposure.pyproves it), so noclimadapackage / conda env is needed and ADR-024 stays intact. - Done: ground-truth checks (no duplicate issues;
milestone:forge-climada-api-archiveunused; adjacent issues #624/#607 are related-not-duplicate catalog packs); planner agent produced this mini-spec, grounded and file-verified. - Issue filed (2026-08-13): #888
— the durable, self-contained handoff (spec + assessor watch-items folded in as
pre-implementation checks + build guidance), labelled
deferred+milestone:forge-climada-api-archive. The assessor agent never ran; its three watch-items are now #888's pre-implementation checklist. - To resume:
/build 888 --worktree. Do NOT re-create the issue — checkgh issue list --label "milestone:forge-climada-api-archive" --state allfirst; #888 already exists. - Assessor watch-items (queued for it, unresolved): (a) standalone script vs. the
existing admin downloads API +
download_to_spacesCelery task — script chosen for local/agent-driven ops use, assessor should sanity-check; (b) whether non-catalog UUIDs underraw/climada-api/…create phantom-orphan findings in the bucket audit tooling (audit regexes currently targethazards/andprocessed/only — likely fine, verify); (c) product axis: this is ops tooling, not MVP-slice work — explicitly owner-requested. - Tropical-cyclone-for-Greece question, resolved 2026-08-12 (does not change this
script's design): a session asked whether this archiver could pull Greek TC data
in place of #624/#607's "non-Greek" scoping. Checked the live API directly — it
does return 17 active
country_iso3alpha=GRCtropical_cyclonerecords — but downloading and opening 3 representative HDF5 files (both track-generation methodologies) found every one has an emptyintensity/dataarray: real events/centroids over Greece, zero non-zero wind intensity. The API's country tag is a metadata-layer false positive; #624/#607's "non-Greek" framing is a verified data constraint, not an oversight — see the comment threads on those issues anddocs/plan/phase-9-full-catalog-walkthrough.md'stropical_cyclonerow. This archiver script is still generically useful for any CLIMADA Data API source (format-agnostic download+manifest+verify) — only the Greek-TC use case is ruled out.
1. Goal¶
One command (python scripts/climada_data.py fetch ...) downloads a chosen dataset/country/hazard/version from the public CLIMADA Data API, archives it verbatim into the production object-storage bucket under an ADR-029 raw/ leaf with a manifest.json, verifies the upload, and deletes the local copy — so the owner never needs a climada conda env or the empty ../climada-datasets side project again.
2. Scope + acceptance criteria¶
- [ ] New
scripts/climada_data.pywithargparsesubcommandslistandfetch, following the repo's script conventions (_PROJECT_ROOTsys.pathfixup,logging.basicConfig,def main(argv: list[str] | None = None) -> int,sys.exit(main())) as inscripts/backfill_dataset_cogs.py:39-214. - [ ] Filters on both subcommands:
--data-type(required forlist),--country(ISO3 →country_iso3alphaquery param),--name,--version,--status(defaultactive).fetchadditionally accepts--uuidto address one record exactly, which bypasses the filter query. - [ ]
listprints a stable one-line-per-dataset table (uuid, name, version, status, file count, total bytes) and exits0on zero matches (empty result is not an error forlist; it is an error forfetch). - [ ] HTTP via
httpxonly (already a first-party dependency,pyproject.toml:47);follow_redirects=True(the non-slash path 301s) againsthttps://climada.ethz.ch/data-api/v1/dataset/. Noclimadaimport anywhere (ADR-024). - [ ]
fetchpipeline, in this order, per file of the resolved record: - Streamed/chunked download (
client.stream("GET", url),iter_bytes(chunk_size=8*1024*1024)) to a local temp dir (--work-dir, defaulttempfile.mkdtemp), writing.tmpthenreplace()— never a whole-file in-memory read. - md5 computed incrementally during the stream and compared to the record's declared
check_sum("md5:<hex>"); mismatch aborts before any upload. - Upload to
raw/climada-api/{data_type}/{version}/{climada-dataset-uuid}/{filename}viabuild_minio_client()+get_settings().minio_bucket. - Verify with
stat_object— uploaded size must equal the local file size. - Write the leaf
manifest.json(same prefix) after all files verify. - Only then delete the local temp files;
--keep-localskips step 6. - [ ]
manifest.jsonprovenance payload: API query URL + resolved record URL, datasetuuid/name/version/data_type/status/license, upstream recordpropertiesverbatim, per-file{filename, object_key, size_bytes, md5, source_url},retrieved_at(UTC ISO-8601), andarchived_by: "scripts/climada_data.py". - [ ] Idempotent: before uploading,
stat_objectthe target key; if it exists with matching size, log and skip (still counted as success, manifest still rewritten).--forcere-uploads unconditionally. - [ ]
--dry-runresolves and prints the planned object keys without downloading or uploading. - [ ] Module docstring documents the ADR-029 deviation explicitly: the leaf
{dataset-id}component is the CLIMADA Data API's own dataset UUID, not ahazard_datasets/exposure_datasetsrow id — deliberate, because archived raw sources have no catalog row. Cross-reference_raw_prefix()insrc/climate_lama/core/catalog.py:148-162, which builds the same shape from a catalog row UUID. - [ ]
{source}component is the literalclimada-api;{version}pins the upstream release label literally (e.g.v3), falling back tov1only when the record carries none — matching_raw_prefix's own fallback. - [ ] Failure semantics: any upload/verify failure leaves all local files in place (no cleanup) and returns a nonzero exit code with a log line naming the object key. Catch
minio.error.S3Errorandhttpx.HTTPErrorspecifically — never bareException. - [ ] Tests at
tests/scripts/test_climada_data.py(mockedhttpxtransport + mockedMinioclient, no Docker, no PostgreSQL, runs in the plainuvenv): listfiltering — params sent includestatus=activeby default; non-dict records are dropped; both bare-list and{"datasets": [...]}response shapes are accepted (the API returns either — see_extract_records,scripts/packs/litpop_exposure.py:550-559).fetchhappy path — correct object key,stat_objectcalled, manifest content asserted, local temp dir empty afterwards.- Idempotent skip — pre-existing key with matching size ⇒ no
put_object, exit0;--force⇒put_objectcalled. - Upload failure (
S3Error) ⇒ local files still present, exit code nonzero, nomanifest.jsonwritten. --keep-local⇒ files retained on success.- [ ]
ruff check+ruff format --checkclean at line length 100;mypy src-config clean for the script's annotations; type hints + Google-style docstrings on every public function. - [ ] Landed per NO-PR-CI: never add the
cilabel; the debounced trunk gate verifies the merge.
3. Out of scope¶
- Any catalog or DB registration — this script creates no
hazard_datasets/exposure_datasetsrows, writes nothing to PostgreSQL, and does not updatecatalog.v1.json. - Ingest packs: #624 (tropical-cyclone pack consuming CLIMADA Data API footprints) and #607 (TC track/HDF5 ingest path) are related, not duplicated — they are catalog-ingest packs; this is a generic raw-source archiver. Neither is implemented or modified here.
- Touching, migrating, or deleting
../climada-datasets(owner does that manually once this lands). - Any UI or
/v1/API surface; any Copernicus/CDS/EWDS source; any format conversion (HDF5→CSV etc. — that already lives inscripts/packs/litpop_exposure.py). - Deploying the script into a container image (
scripts/is not copied into the images exceptseed_demo.py) — it runs from a clone with the repo's own env.
4. Proposed decomposition¶
ONE work item, size M. No sensible split: the manifest, verify, and cleanup steps are one transaction and would be untestable apart.
Files touched:
- scripts/climada_data.py (new, ~350–450 lines incl. docstring)
- tests/scripts/test_climada_data.py (new)
- No changes to src/, migrations/, pyproject.toml, or Docker/compose.
5. Milestone id¶
forge-climada-api-archive
6. Open design points resolved¶
- Response shape / pagination. The API returns either a bare JSON list or a mapping with
datasets/data; there is no pagination cursor in the observed contract. Reuse the normalisation logic proven in_extract_records(litpop_exposure.py:550) rather than assuming a list. Records carryuuid,name,version,status,license,properties(dict), andfiles(list of{url, file_size, check_sum, file_name}) — confirmed by the same file'sclimada_api_identity()usage at lines 349-396. - Country filter mapping. ISO3 maps to the query param
country_iso3alpha(notcountry), as used atlitpop_exposure.py:516-520;data_typeandstatusare passed under their own names. - HTTP client choice.
httpx— a top-level dependency (pyproject.toml:47);requests>=2.32appears only in an optional extra group (line 126), sohttpxis the correct choice for a script runnable from the base env. - Checksum field. The API's
check_sumis prefixedmd5:; strip the prefix before comparing. Verified byte-exact for at least one dataset per the litpop pack docstring (line 106), so treating it as authoritative is safe — but a missingcheck_summust warn and continue rather than abort (not all records are guaranteed to carry one). stat_objectfailure modes. Three distinct cases, handled separately: (a)S3Errorwithcode == "NoSuchKey"on the pre-check ⇒ normal "not yet archived", proceed; (b)S3Errorwith any other code (e.g.AccessDenied,NoSuchBucket) ⇒ fatal, exit nonzero without touching local files; (c) post-uploadstat_objectsucceeds but size mismatches ⇒ fatal, keep local files, log both sizes. Do not usestat_objecttruthiness — it raises, it does not returnNone.- Credentials. Sourced entirely from
get_settings()viabuild_minio_client()(src/climate_lama/worker/ingest/_minio.py:24-32) — no endpoint or key literals in the script. Pointing at production means running with the production.env; the script logs the resolved bucket name (never the credentials) at INFO before its first write.