usdata 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {usdata-0.3.0 → usdata-0.4.0}/PKG-INFO +7 -4
- {usdata-0.3.0 → usdata-0.4.0}/README.md +6 -3
- {usdata-0.3.0 → usdata-0.4.0}/pyproject.toml +1 -1
- {usdata-0.3.0 → usdata-0.4.0}/pyproject.toml.orig +1 -1
- usdata-0.4.0/src/usdata/_files.py +29 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cache.py +14 -2
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cli/app.py +14 -9
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/registry.yaml +7 -7
- usdata-0.4.0/src/usdata/fetch.py +79 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/manifest.py +30 -3
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/http.py +2 -7
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/provenance.py +2 -1
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/base.py +17 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/coastwatch.py +1 -1
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/ghcnd.py +7 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/nexrad.py +11 -1
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/pull.py +22 -15
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/registry.py +2 -1
- usdata-0.3.0/src/usdata/fetch.py +0 -54
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/__init__.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/places.yaml +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/models.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/py.typed +0 -0
- {usdata-0.3.0 → usdata-0.4.0}/src/usdata/query.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data
|
|
5
5
|
Keywords: noaa,usgs,nasa,open-data,scientific-data,provenance
|
|
6
6
|
Author: Jake Van Slyke
|
|
@@ -31,15 +31,15 @@ provenance of U.S. public scientific data (NOAA, USGS, NASA, and more).
|
|
|
31
31
|
## Providers
|
|
32
32
|
|
|
33
33
|
<!-- registry:start -->
|
|
34
|
-
| Provider | Available | Stub | Planned | Next up (0.
|
|
34
|
+
| Provider | Available | Stub | Planned | Next up (0.5) | Datasets |
|
|
35
35
|
|---|---:|---:|---:|---|---|
|
|
36
|
-
| [NOAA](docs/providers/noaa.md) | 2 | 1 | 26 | `coastwatch-sst` | `ghcn-daily`, `nexrad-level2`, _coastwatch-sst_, +26 planned |
|
|
36
|
+
| [NOAA](docs/providers/noaa.md) | 2 | 1 | 26 | `gsom`, `gsoy`, `storm-events`, `mrms`, `goes-abi`, `hurdat2`, `ibtracs`, `climate-normals`, `coops-water-levels`, `coastwatch-sst` | `ghcn-daily`, `nexrad-level2`, _coastwatch-sst_, +26 planned |
|
|
37
37
|
| [Census Bureau](docs/providers/census.md) | 0 | 0 | 1 | — | +1 planned |
|
|
38
38
|
| [EPA](docs/providers/epa.md) | 0 | 0 | 1 | — | +1 planned |
|
|
39
39
|
| [FEMA](docs/providers/fema.md) | 0 | 0 | 1 | — | +1 planned |
|
|
40
40
|
| [NASA](docs/providers/nasa.md) | 0 | 0 | 1 | — | +1 planned |
|
|
41
41
|
| [USDA](docs/providers/usda.md) | 0 | 0 | 1 | — | +1 planned |
|
|
42
|
-
| [USGS](docs/providers/usgs.md) | 0 | 0 | 3 |
|
|
42
|
+
| [USGS](docs/providers/usgs.md) | 0 | 0 | 3 | `water-daily` | +3 planned |
|
|
43
43
|
|
|
44
44
|
Available datasets are in `code`, stubs in _italics_; planned ones are counted. Each provider page has access notes and full dataset details; [docs/roadmap.md](docs/roadmap.md) lists datasets by target version.
|
|
45
45
|
<!-- registry:end -->
|
|
@@ -89,6 +89,9 @@ Fetched files land in `~/.cache/usdata/<provider>/<dataset>/` (override with
|
|
|
89
89
|
`USDATA_CACHE_DIR` or `--cache-dir`), each with a `.provenance.json` sidecar
|
|
90
90
|
recording source URL, retrieval time, checksum, size, and license.
|
|
91
91
|
|
|
92
|
+
Manifest and source fields are validated strictly; unknown fields are errors.
|
|
93
|
+
Provider-specific options belong under `params`.
|
|
94
|
+
|
|
92
95
|
A manifest declares every input a project needs. `pull` resolves each source,
|
|
93
96
|
fetches it, and writes `dataset.lock.json` pinning every asset with its checksum
|
|
94
97
|
and provenance. A second `pull` restores exactly what the lockfile pins without
|
|
@@ -10,15 +10,15 @@ provenance of U.S. public scientific data (NOAA, USGS, NASA, and more).
|
|
|
10
10
|
## Providers
|
|
11
11
|
|
|
12
12
|
<!-- registry:start -->
|
|
13
|
-
| Provider | Available | Stub | Planned | Next up (0.
|
|
13
|
+
| Provider | Available | Stub | Planned | Next up (0.5) | Datasets |
|
|
14
14
|
|---|---:|---:|---:|---|---|
|
|
15
|
-
| [NOAA](docs/providers/noaa.md) | 2 | 1 | 26 | `coastwatch-sst` | `ghcn-daily`, `nexrad-level2`, _coastwatch-sst_, +26 planned |
|
|
15
|
+
| [NOAA](docs/providers/noaa.md) | 2 | 1 | 26 | `gsom`, `gsoy`, `storm-events`, `mrms`, `goes-abi`, `hurdat2`, `ibtracs`, `climate-normals`, `coops-water-levels`, `coastwatch-sst` | `ghcn-daily`, `nexrad-level2`, _coastwatch-sst_, +26 planned |
|
|
16
16
|
| [Census Bureau](docs/providers/census.md) | 0 | 0 | 1 | — | +1 planned |
|
|
17
17
|
| [EPA](docs/providers/epa.md) | 0 | 0 | 1 | — | +1 planned |
|
|
18
18
|
| [FEMA](docs/providers/fema.md) | 0 | 0 | 1 | — | +1 planned |
|
|
19
19
|
| [NASA](docs/providers/nasa.md) | 0 | 0 | 1 | — | +1 planned |
|
|
20
20
|
| [USDA](docs/providers/usda.md) | 0 | 0 | 1 | — | +1 planned |
|
|
21
|
-
| [USGS](docs/providers/usgs.md) | 0 | 0 | 3 |
|
|
21
|
+
| [USGS](docs/providers/usgs.md) | 0 | 0 | 3 | `water-daily` | +3 planned |
|
|
22
22
|
|
|
23
23
|
Available datasets are in `code`, stubs in _italics_; planned ones are counted. Each provider page has access notes and full dataset details; [docs/roadmap.md](docs/roadmap.md) lists datasets by target version.
|
|
24
24
|
<!-- registry:end -->
|
|
@@ -68,6 +68,9 @@ Fetched files land in `~/.cache/usdata/<provider>/<dataset>/` (override with
|
|
|
68
68
|
`USDATA_CACHE_DIR` or `--cache-dir`), each with a `.provenance.json` sidecar
|
|
69
69
|
recording source URL, retrieval time, checksum, size, and license.
|
|
70
70
|
|
|
71
|
+
Manifest and source fields are validated strictly; unknown fields are errors.
|
|
72
|
+
Provider-specific options belong under `params`.
|
|
73
|
+
|
|
71
74
|
A manifest declares every input a project needs. `pull` resolves each source,
|
|
72
75
|
fetches it, and writes `dataset.lock.json` pinning every asset with its checksum
|
|
73
76
|
and provenance. A second `pull` restores exactly what the lockfile pins without
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Atomic replacement helpers for downloads and JSON records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import NamedTemporaryFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def staged_path(dest: Path) -> Iterator[Path]:
|
|
13
|
+
"""Replace dest only after successful work in a unique sibling temporary file."""
|
|
14
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
with NamedTemporaryFile(
|
|
16
|
+
prefix=f".{dest.name}.", suffix=".part", dir=dest.parent, delete=False
|
|
17
|
+
) as f:
|
|
18
|
+
tmp = Path(f.name)
|
|
19
|
+
try:
|
|
20
|
+
yield tmp
|
|
21
|
+
tmp.replace(dest)
|
|
22
|
+
finally:
|
|
23
|
+
tmp.unlink(missing_ok=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def atomic_write_text(dest: Path, text: str) -> None:
|
|
27
|
+
"""Write UTF-8 text without exposing an incomplete record to readers."""
|
|
28
|
+
with staged_path(dest) as tmp:
|
|
29
|
+
tmp.write_text(text, encoding="utf-8")
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from usdata.models import Asset
|
|
@@ -22,9 +23,20 @@ def cache_dir() -> Path:
|
|
|
22
23
|
|
|
23
24
|
def asset_path(asset: Asset, root: Path | None = None) -> Path:
|
|
24
25
|
"""Where an asset lives in the cache: <root>/<provider>/<dataset>/<asset id>."""
|
|
25
|
-
provider, name = asset.dataset_id.
|
|
26
|
+
provider, sep, name = asset.dataset_id.partition(":")
|
|
27
|
+
if not sep or any(
|
|
28
|
+
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in (provider, name)
|
|
29
|
+
):
|
|
30
|
+
raise ValueError(f"unsafe dataset id: {asset.dataset_id!r}")
|
|
26
31
|
safe_id = asset.id.replace("/", "_")
|
|
27
|
-
|
|
32
|
+
if not safe_id or safe_id in {".", ".."} or "\\" in safe_id or "\x00" in safe_id:
|
|
33
|
+
raise ValueError(f"unsafe asset id: {asset.id!r}")
|
|
34
|
+
base = (root or cache_dir()).expanduser().resolve()
|
|
35
|
+
path = base / provider / name / safe_id
|
|
36
|
+
for candidate in (path, path.with_name(path.name + ".provenance.json")):
|
|
37
|
+
if not candidate.resolve().is_relative_to(base):
|
|
38
|
+
raise ValueError(f"cache path escapes root: {candidate}")
|
|
39
|
+
return path
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
def sha256_file(path: Path, chunk_size: int = 1 << 20) -> str:
|
|
@@ -9,7 +9,8 @@ import httpx
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
11
11
|
from usdata import __version__, build_query, default_registry
|
|
12
|
-
from usdata.fetch import ChecksumMismatch
|
|
12
|
+
from usdata.fetch import ChecksumMismatch
|
|
13
|
+
from usdata.fetch import fetch as fetch_query
|
|
13
14
|
from usdata.manifest import lockfile_path
|
|
14
15
|
from usdata.providers import load_adapter
|
|
15
16
|
from usdata.providers.base import NotImplementedProvider
|
|
@@ -59,8 +60,8 @@ def search(
|
|
|
59
60
|
"""Search the curated dataset registry."""
|
|
60
61
|
try:
|
|
61
62
|
query = build_query(text, provider=provider, location=state, start=start, end=end)
|
|
62
|
-
except
|
|
63
|
-
typer.secho(
|
|
63
|
+
except ValueError as e:
|
|
64
|
+
typer.secho(str(e), err=True, fg="red")
|
|
64
65
|
raise typer.Exit(code=2) from None
|
|
65
66
|
results = default_registry().search(query, include_planned=planned)
|
|
66
67
|
if not results:
|
|
@@ -152,21 +153,21 @@ def fetch(
|
|
|
152
153
|
variables=[v.strip() for v in variables.split(",")] if variables else None,
|
|
153
154
|
**params,
|
|
154
155
|
)
|
|
155
|
-
adapter = load_adapter(ds)
|
|
156
|
-
assets = adapter.list_assets(query)
|
|
157
156
|
if dry_run:
|
|
157
|
+
with load_adapter(ds) as adapter:
|
|
158
|
+
assets = adapter.list_assets(query)
|
|
158
159
|
for a in assets:
|
|
159
160
|
typer.echo(f"{a.id}\t{a.href}")
|
|
160
161
|
typer.echo(f"{len(assets)} asset(s) matched", err=True)
|
|
161
162
|
return
|
|
162
|
-
fetched =
|
|
163
|
+
fetched = fetch_query(ds, query, root=cache_dir, force=force)
|
|
163
164
|
except (DatasetNotFound, UnknownPlace, ValueError) as e:
|
|
164
165
|
typer.secho(str(e), err=True, fg="red")
|
|
165
166
|
raise typer.Exit(code=2) from None
|
|
166
167
|
except NotImplementedProvider as e:
|
|
167
168
|
typer.secho(str(e), err=True, fg="yellow")
|
|
168
169
|
raise typer.Exit(code=3) from None
|
|
169
|
-
except httpx.HTTPError as e:
|
|
170
|
+
except (httpx.HTTPError, ChecksumMismatch) as e:
|
|
170
171
|
typer.secho(f"request failed: {e}", err=True, fg="red")
|
|
171
172
|
raise typer.Exit(code=4) from None
|
|
172
173
|
if not fetched:
|
|
@@ -189,7 +190,7 @@ def pull(
|
|
|
189
190
|
"""Fetch every source in a manifest and write (or restore from) its lockfile."""
|
|
190
191
|
try:
|
|
191
192
|
result = pull_manifest(manifest, root=cache_dir, force=force)
|
|
192
|
-
except (UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
|
|
193
|
+
except (DatasetNotFound, UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
|
|
193
194
|
typer.secho(str(e), err=True, fg="red")
|
|
194
195
|
raise typer.Exit(code=2) from None
|
|
195
196
|
except NotImplementedProvider as e:
|
|
@@ -215,7 +216,11 @@ def verify(
|
|
|
215
216
|
if not lock.exists():
|
|
216
217
|
typer.secho(f"no lockfile at {lock}; run pull first", err=True, fg="red")
|
|
217
218
|
raise typer.Exit(code=2)
|
|
218
|
-
|
|
219
|
+
try:
|
|
220
|
+
drift = verify_manifest(manifest, root=cache_dir)
|
|
221
|
+
except (ValueError, OSError) as e:
|
|
222
|
+
typer.secho(str(e), err=True, fg="red")
|
|
223
|
+
raise typer.Exit(code=2) from None
|
|
219
224
|
for d in drift:
|
|
220
225
|
typer.echo(f"{d.asset_id}\t{d.problem}\t{d.path}")
|
|
221
226
|
if drift:
|
|
@@ -87,7 +87,7 @@ datasets:
|
|
|
87
87
|
provider: noaa
|
|
88
88
|
status: stub
|
|
89
89
|
domain: satellite-oceanography
|
|
90
|
-
target: "0.
|
|
90
|
+
target: "0.5"
|
|
91
91
|
title: CoastWatch Blended Sea Surface Temperature
|
|
92
92
|
description: >-
|
|
93
93
|
NOAA geo-polar blended daily SST analysis (day and night) on a global
|
|
@@ -105,7 +105,7 @@ datasets:
|
|
|
105
105
|
provider: noaa
|
|
106
106
|
status: planned
|
|
107
107
|
domain: weather-satellites
|
|
108
|
-
target: "0.
|
|
108
|
+
target: "0.5"
|
|
109
109
|
title: GOES-R ABI Satellite Imagery
|
|
110
110
|
description: >-
|
|
111
111
|
Advanced Baseline Imager products from GOES-16, 18, and 19 in the public
|
|
@@ -139,7 +139,7 @@ datasets:
|
|
|
139
139
|
provider: noaa
|
|
140
140
|
status: planned
|
|
141
141
|
domain: severe-weather
|
|
142
|
-
target: "0.
|
|
142
|
+
target: "0.5"
|
|
143
143
|
title: Storm Events Database
|
|
144
144
|
description: >-
|
|
145
145
|
NCEI's record of significant weather events since 1950 (tornadoes, hail,
|
|
@@ -157,7 +157,7 @@ datasets:
|
|
|
157
157
|
provider: noaa
|
|
158
158
|
status: planned
|
|
159
159
|
domain: tropical-cyclones
|
|
160
|
-
target: "0.
|
|
160
|
+
target: "0.5"
|
|
161
161
|
title: HURDAT2 Atlantic and Pacific Best Tracks
|
|
162
162
|
description: >-
|
|
163
163
|
National Hurricane Center best-track database: six-hourly position,
|
|
@@ -175,7 +175,7 @@ datasets:
|
|
|
175
175
|
provider: noaa
|
|
176
176
|
status: planned
|
|
177
177
|
domain: tropical-cyclones
|
|
178
|
-
target: "0.
|
|
178
|
+
target: "0.5"
|
|
179
179
|
title: IBTrACS Global Tropical Cyclone Tracks
|
|
180
180
|
description: >-
|
|
181
181
|
International Best Track Archive for Climate Stewardship: merged best
|
|
@@ -538,7 +538,7 @@ datasets:
|
|
|
538
538
|
provider: usgs
|
|
539
539
|
status: planned
|
|
540
540
|
domain: water-resources
|
|
541
|
-
target: "0.
|
|
541
|
+
target: "0.5"
|
|
542
542
|
title: Streamflow and Water Daily Values
|
|
543
543
|
description: >-
|
|
544
544
|
Daily statistics (streamflow, gage height, temperature) for USGS
|
|
@@ -587,7 +587,7 @@ datasets:
|
|
|
587
587
|
provider: nasa
|
|
588
588
|
status: planned
|
|
589
589
|
domain: weather-satellites
|
|
590
|
-
target: "0.
|
|
590
|
+
target: "0.6"
|
|
591
591
|
title: GPM IMERG Precipitation
|
|
592
592
|
description: >-
|
|
593
593
|
Global half-hourly and daily merged satellite precipitation estimates.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Core fetch loop: resolve a query, download assets through the cache, record provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from usdata import provenance
|
|
10
|
+
from usdata._files import staged_path
|
|
11
|
+
from usdata.cache import asset_path, sha256_file
|
|
12
|
+
from usdata.models import Asset, Dataset, Provenance, Query
|
|
13
|
+
from usdata.providers import Provider, load_adapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChecksumMismatch(RuntimeError):
|
|
17
|
+
"""A fetched file's sha256 did not match the checksum the adapter declared."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FetchedAsset(BaseModel):
|
|
21
|
+
"""One asset on disk with its provenance and whether the cache satisfied it."""
|
|
22
|
+
|
|
23
|
+
asset: Asset
|
|
24
|
+
path: Path
|
|
25
|
+
provenance: Provenance
|
|
26
|
+
from_cache: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fetch_asset(
|
|
30
|
+
dataset: Dataset,
|
|
31
|
+
asset: Asset,
|
|
32
|
+
adapter: Provider,
|
|
33
|
+
*,
|
|
34
|
+
root: Path | None = None,
|
|
35
|
+
force: bool = False,
|
|
36
|
+
) -> FetchedAsset:
|
|
37
|
+
if asset.dataset_id != dataset.id:
|
|
38
|
+
raise ValueError(f"asset dataset {asset.dataset_id!r} does not match {dataset.id!r}")
|
|
39
|
+
path = asset_path(asset, root)
|
|
40
|
+
if not force and path.is_file():
|
|
41
|
+
try:
|
|
42
|
+
prov = provenance.read(path)
|
|
43
|
+
except (ValueError, OSError):
|
|
44
|
+
prov = None
|
|
45
|
+
if (
|
|
46
|
+
prov is not None
|
|
47
|
+
and prov.dataset_id == dataset.id
|
|
48
|
+
and prov.provider == dataset.provider
|
|
49
|
+
and prov.source_url == asset.href
|
|
50
|
+
and prov.size == path.stat().st_size
|
|
51
|
+
and (asset.checksum is None or prov.checksum == asset.checksum)
|
|
52
|
+
and sha256_file(path) == prov.checksum
|
|
53
|
+
):
|
|
54
|
+
return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=True)
|
|
55
|
+
with staged_path(path) as tmp:
|
|
56
|
+
adapter.fetch(asset, tmp)
|
|
57
|
+
prov = provenance.record(dataset, asset, tmp)
|
|
58
|
+
if asset.checksum and prov.checksum != asset.checksum:
|
|
59
|
+
raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {prov.checksum}")
|
|
60
|
+
# A crash between replacements leaves a detectable mismatch, never a trusted partial file.
|
|
61
|
+
provenance.write(prov, path)
|
|
62
|
+
return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_asset(
|
|
66
|
+
dataset: Dataset, asset: Asset, *, root: Path | None = None, force: bool = False
|
|
67
|
+
) -> FetchedAsset:
|
|
68
|
+
"""Fetch one asset, reusing the cache only when bytes and provenance agree."""
|
|
69
|
+
with load_adapter(dataset) as adapter:
|
|
70
|
+
return _fetch_asset(dataset, asset, adapter, root=root, force=force)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fetch(
|
|
74
|
+
dataset: Dataset, query: Query, *, root: Path | None = None, force: bool = False
|
|
75
|
+
) -> list[FetchedAsset]:
|
|
76
|
+
"""Resolve and fetch a query, sharing one adapter and closing its owned resources."""
|
|
77
|
+
with load_adapter(dataset) as adapter:
|
|
78
|
+
assets = adapter.list_assets(query)
|
|
79
|
+
return [_fetch_asset(dataset, a, adapter, root=root, force=force) for a in assets]
|
|
@@ -7,8 +7,9 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
import yaml
|
|
10
|
-
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
11
11
|
|
|
12
|
+
from usdata._files import atomic_write_text
|
|
12
13
|
from usdata.models import Asset, BBox, Provenance, Query
|
|
13
14
|
from usdata.query import build_query
|
|
14
15
|
from usdata.registry import Registry, default_registry
|
|
@@ -17,6 +18,8 @@ from usdata.registry import Registry, default_registry
|
|
|
17
18
|
class SourceSpec(BaseModel):
|
|
18
19
|
"""One entry under ``sources:`` in a manifest."""
|
|
19
20
|
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
20
23
|
dataset: str
|
|
21
24
|
location: str | None = None
|
|
22
25
|
bbox: BBox | None = None
|
|
@@ -25,6 +28,13 @@ class SourceSpec(BaseModel):
|
|
|
25
28
|
variables: list[str] = Field(default_factory=list)
|
|
26
29
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
27
30
|
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _reserved_params(self) -> SourceSpec:
|
|
33
|
+
reserved = {"location", "bbox", "start", "end", "variables"}
|
|
34
|
+
if overlap := reserved.intersection(self.params):
|
|
35
|
+
raise ValueError(f"params contains reserved query fields: {', '.join(sorted(overlap))}")
|
|
36
|
+
return self
|
|
37
|
+
|
|
28
38
|
def to_query(self) -> Query:
|
|
29
39
|
"""Build the Query this source resolves to."""
|
|
30
40
|
return build_query(
|
|
@@ -40,6 +50,8 @@ class SourceSpec(BaseModel):
|
|
|
40
50
|
class Manifest(BaseModel):
|
|
41
51
|
"""A declarative list of inputs a project needs: usdata pull fetches them."""
|
|
42
52
|
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
43
55
|
name: str
|
|
44
56
|
version: str = "1.0"
|
|
45
57
|
sources: list[SourceSpec] = Field(min_length=1)
|
|
@@ -47,7 +59,11 @@ class Manifest(BaseModel):
|
|
|
47
59
|
@classmethod
|
|
48
60
|
def load(cls, path: Path) -> Manifest:
|
|
49
61
|
"""Parse a manifest YAML file."""
|
|
50
|
-
|
|
62
|
+
try:
|
|
63
|
+
raw = yaml.safe_load(path.read_text())
|
|
64
|
+
except yaml.YAMLError as e:
|
|
65
|
+
raise ValueError(f"invalid manifest YAML in {path}: {e}") from e
|
|
66
|
+
return cls.model_validate(raw or {})
|
|
51
67
|
|
|
52
68
|
def validate_against(self, registry: Registry | None = None) -> list[str]:
|
|
53
69
|
"""Return the dataset ids referenced by this manifest that the registry lacks."""
|
|
@@ -61,6 +77,17 @@ class LockedAsset(BaseModel):
|
|
|
61
77
|
asset: Asset
|
|
62
78
|
provenance: Provenance
|
|
63
79
|
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _consistent(self) -> LockedAsset:
|
|
82
|
+
if (
|
|
83
|
+
self.asset.dataset_id != self.provenance.dataset_id
|
|
84
|
+
or self.asset.href != self.provenance.source_url
|
|
85
|
+
):
|
|
86
|
+
raise ValueError("locked asset and provenance must identify the same source")
|
|
87
|
+
if self.asset.checksum is not None and self.asset.checksum != self.provenance.checksum:
|
|
88
|
+
raise ValueError("locked asset and provenance checksums must agree")
|
|
89
|
+
return self
|
|
90
|
+
|
|
64
91
|
|
|
65
92
|
class Lockfile(BaseModel):
|
|
66
93
|
"""Exactly what a manifest resolved to, with checksums, so it can be reproduced."""
|
|
@@ -78,7 +105,7 @@ class Lockfile(BaseModel):
|
|
|
78
105
|
|
|
79
106
|
def save(self, path: Path) -> None:
|
|
80
107
|
"""Write the lockfile as indented JSON."""
|
|
81
|
-
path
|
|
108
|
+
atomic_write_text(path, self.model_dump_json(indent=2))
|
|
82
109
|
|
|
83
110
|
|
|
84
111
|
def lockfile_path(manifest_path: Path) -> Path:
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
10
|
from usdata import __version__
|
|
11
|
+
from usdata._files import staged_path
|
|
11
12
|
|
|
12
13
|
USER_AGENT = f"usdata/{__version__} (+https://github.com/jakeryderv/usdata)"
|
|
13
14
|
DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=120.0)
|
|
@@ -23,20 +24,14 @@ def client(**kwargs: Any) -> httpx.Client:
|
|
|
23
24
|
|
|
24
25
|
def download(url: str, dest: Path, http: httpx.Client | None = None) -> Path:
|
|
25
26
|
"""Stream ``url`` to ``dest`` atomically. Raises ``httpx.HTTPStatusError`` on 4xx/5xx."""
|
|
26
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
-
tmp = dest.with_name(dest.name + ".part")
|
|
28
27
|
own = http is None
|
|
29
28
|
http = http or client()
|
|
30
29
|
try:
|
|
31
|
-
with http.stream("GET", url) as resp:
|
|
30
|
+
with staged_path(dest) as tmp, http.stream("GET", url) as resp:
|
|
32
31
|
resp.raise_for_status()
|
|
33
32
|
with tmp.open("wb") as f:
|
|
34
33
|
for chunk in resp.iter_bytes():
|
|
35
34
|
f.write(chunk)
|
|
36
|
-
tmp.replace(dest)
|
|
37
|
-
except BaseException:
|
|
38
|
-
tmp.unlink(missing_ok=True)
|
|
39
|
-
raise
|
|
40
35
|
finally:
|
|
41
36
|
if own:
|
|
42
37
|
http.close()
|
|
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from usdata import __version__
|
|
9
|
+
from usdata._files import atomic_write_text
|
|
9
10
|
from usdata.cache import sha256_file
|
|
10
11
|
from usdata.models import Asset, Dataset, Provenance
|
|
11
12
|
|
|
@@ -34,7 +35,7 @@ def sidecar_path(path: Path) -> Path:
|
|
|
34
35
|
def write(prov: Provenance, path: Path) -> Path:
|
|
35
36
|
"""Write a provenance record beside ``path`` and return the sidecar path."""
|
|
36
37
|
out = sidecar_path(path)
|
|
37
|
-
out
|
|
38
|
+
atomic_write_text(out, prov.model_dump_json(indent=2))
|
|
38
39
|
return out
|
|
39
40
|
|
|
40
41
|
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import importlib
|
|
6
6
|
from abc import ABC, abstractmethod
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Self
|
|
8
10
|
|
|
9
11
|
from usdata.models import Asset, Dataset, Query
|
|
10
12
|
|
|
@@ -23,6 +25,21 @@ class Provider(ABC):
|
|
|
23
25
|
def __init__(self, dataset: Dataset) -> None:
|
|
24
26
|
self.dataset = dataset
|
|
25
27
|
|
|
28
|
+
def __enter__(self) -> Self:
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(
|
|
32
|
+
self,
|
|
33
|
+
exc_type: type[BaseException] | None,
|
|
34
|
+
exc: BaseException | None,
|
|
35
|
+
traceback: TracebackType | None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.close()
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Release owned resources. Providers with resources override this method."""
|
|
41
|
+
return None
|
|
42
|
+
|
|
26
43
|
@abstractmethod
|
|
27
44
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
28
45
|
"""Resolve a query to the concrete objects that satisfy it, without downloading."""
|
|
@@ -44,6 +44,13 @@ class GhcnDaily(Provider):
|
|
|
44
44
|
def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
|
|
45
45
|
super().__init__(dataset)
|
|
46
46
|
self._client = client
|
|
47
|
+
self._owns_client = client is None
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
"""Close an internally created HTTP client; injected clients belong to the caller."""
|
|
51
|
+
if self._owns_client and self._client is not None:
|
|
52
|
+
self._client.close()
|
|
53
|
+
self._client = None
|
|
47
54
|
|
|
48
55
|
def _http(self) -> httpx.Client:
|
|
49
56
|
if self._client is None:
|
|
@@ -47,6 +47,13 @@ class NexradLevel2(Provider):
|
|
|
47
47
|
def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
|
|
48
48
|
super().__init__(dataset)
|
|
49
49
|
self._client = client
|
|
50
|
+
self._owns_client = client is None
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
"""Close an internally created HTTP client; injected clients belong to the caller."""
|
|
54
|
+
if self._owns_client and self._client is not None:
|
|
55
|
+
self._client.close()
|
|
56
|
+
self._client = None
|
|
50
57
|
|
|
51
58
|
def _http(self) -> httpx.Client:
|
|
52
59
|
if self._client is None:
|
|
@@ -59,7 +66,10 @@ class NexradLevel2(Provider):
|
|
|
59
66
|
if raw is not None:
|
|
60
67
|
ids = _sites_param(raw)
|
|
61
68
|
for sid in ids:
|
|
62
|
-
|
|
69
|
+
try:
|
|
70
|
+
sites.get_site(sid)
|
|
71
|
+
except KeyError as e:
|
|
72
|
+
raise QueryError(str(e)) from e
|
|
63
73
|
return ids
|
|
64
74
|
if query.bbox is None:
|
|
65
75
|
raise QueryError(f"{self.dataset.id} needs a location, bbox, lat/lon, or site=...")
|
|
@@ -10,6 +10,7 @@ fetching anything.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
from contextlib import ExitStack
|
|
13
14
|
from datetime import UTC, datetime
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
@@ -17,9 +18,9 @@ from pydantic import BaseModel
|
|
|
17
18
|
|
|
18
19
|
from usdata import __version__, provenance
|
|
19
20
|
from usdata.cache import asset_path, sha256_file
|
|
20
|
-
from usdata.fetch import ChecksumMismatch, FetchedAsset,
|
|
21
|
+
from usdata.fetch import ChecksumMismatch, FetchedAsset, _fetch_asset, fetch
|
|
21
22
|
from usdata.manifest import LockedAsset, Lockfile, Manifest, lockfile_path
|
|
22
|
-
from usdata.providers import load_adapter
|
|
23
|
+
from usdata.providers import Provider, load_adapter
|
|
23
24
|
from usdata.registry import Registry, default_registry
|
|
24
25
|
|
|
25
26
|
|
|
@@ -67,10 +68,9 @@ def resolve(
|
|
|
67
68
|
locked: list[LockedAsset] = []
|
|
68
69
|
for source in manifest.sources:
|
|
69
70
|
dataset = reg.get(source.dataset)
|
|
70
|
-
for
|
|
71
|
-
item = fetch_asset(dataset, asset, root=root)
|
|
71
|
+
for item in fetch(dataset, source.to_query(), root=root):
|
|
72
72
|
fetched.append(item)
|
|
73
|
-
pinned = asset.model_copy(update={"checksum": item.provenance.checksum})
|
|
73
|
+
pinned = item.asset.model_copy(update={"checksum": item.provenance.checksum})
|
|
74
74
|
locked.append(LockedAsset(asset=pinned, provenance=item.provenance))
|
|
75
75
|
lock = Lockfile(
|
|
76
76
|
manifest=manifest.name,
|
|
@@ -97,18 +97,25 @@ def restore(
|
|
|
97
97
|
"pull with force to re-resolve"
|
|
98
98
|
)
|
|
99
99
|
fetched: list[FetchedAsset] = []
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
adapters: dict[str, Provider] = {}
|
|
101
|
+
with ExitStack() as stack:
|
|
102
|
+
for entry in lock.assets:
|
|
103
|
+
dataset = reg.get(entry.asset.dataset_id)
|
|
104
|
+
path = asset_path(entry.asset, root)
|
|
105
|
+
if path.is_file() and sha256_file(path) == entry.provenance.checksum:
|
|
106
|
+
provenance.write(entry.provenance, path)
|
|
107
|
+
fetched.append(
|
|
108
|
+
FetchedAsset(
|
|
109
|
+
asset=entry.asset, path=path, provenance=entry.provenance, from_cache=True
|
|
110
|
+
)
|
|
107
111
|
)
|
|
112
|
+
continue
|
|
113
|
+
if dataset.id not in adapters:
|
|
114
|
+
adapters[dataset.id] = stack.enter_context(load_adapter(dataset))
|
|
115
|
+
pinned = entry.asset.model_copy(update={"checksum": entry.provenance.checksum})
|
|
116
|
+
fetched.append(
|
|
117
|
+
_fetch_asset(dataset, pinned, adapters[dataset.id], root=root, force=True)
|
|
108
118
|
)
|
|
109
|
-
continue
|
|
110
|
-
# fetch_asset verifies against asset.checksum, which resolve() pinned.
|
|
111
|
-
fetched.append(fetch_asset(dataset, entry.asset, root=root, force=True))
|
|
112
119
|
return PullResult(lockfile=lock, lockfile_path=lock_path, fetched=fetched, from_lockfile=True)
|
|
113
120
|
|
|
114
121
|
|
|
@@ -82,11 +82,12 @@ class Registry:
|
|
|
82
82
|
self._by_id[ds.id] = ds
|
|
83
83
|
self._providers = {p.id: p for p in providers}
|
|
84
84
|
self._domains = {d.id: d for d in domains}
|
|
85
|
+
explicit_domains = bool(self._domains)
|
|
85
86
|
for ds in self._by_id.values():
|
|
86
87
|
self._providers.setdefault(
|
|
87
88
|
ds.provider, ProviderInfo(id=ds.provider, name=ds.provider.upper())
|
|
88
89
|
)
|
|
89
|
-
if
|
|
90
|
+
if explicit_domains and ds.domain not in self._domains:
|
|
90
91
|
raise ValueError(f"{ds.id}: unknown domain {ds.domain!r}")
|
|
91
92
|
self._domains.setdefault(ds.domain, DomainInfo(id=ds.domain, name=ds.domain))
|
|
92
93
|
|
usdata-0.3.0/src/usdata/fetch.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
"""Core fetch loop: resolve a query, download assets through the cache, record provenance."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from pydantic import BaseModel
|
|
8
|
-
|
|
9
|
-
from usdata import provenance
|
|
10
|
-
from usdata.cache import asset_path, sha256_file
|
|
11
|
-
from usdata.models import Asset, Dataset, Provenance, Query
|
|
12
|
-
from usdata.providers import load_adapter
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class ChecksumMismatch(RuntimeError):
|
|
16
|
-
"""A fetched file's sha256 did not match the checksum the adapter declared."""
|
|
17
|
-
|
|
18
|
-
pass
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class FetchedAsset(BaseModel):
|
|
22
|
-
"""One asset on disk with its provenance and whether the cache satisfied it."""
|
|
23
|
-
|
|
24
|
-
asset: Asset
|
|
25
|
-
path: Path
|
|
26
|
-
provenance: Provenance
|
|
27
|
-
from_cache: bool
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def fetch_asset(
|
|
31
|
-
dataset: Dataset, asset: Asset, *, root: Path | None = None, force: bool = False
|
|
32
|
-
) -> FetchedAsset:
|
|
33
|
-
"""Fetch one asset via its provider unless it is already cached with a provenance sidecar."""
|
|
34
|
-
path = asset_path(asset, root)
|
|
35
|
-
if not force and path.exists() and provenance.sidecar_path(path).exists():
|
|
36
|
-
return FetchedAsset(
|
|
37
|
-
asset=asset, path=path, provenance=provenance.read(path), from_cache=True
|
|
38
|
-
)
|
|
39
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
-
load_adapter(dataset).fetch(asset, path)
|
|
41
|
-
if asset.checksum and (got := sha256_file(path)) != asset.checksum:
|
|
42
|
-
path.unlink(missing_ok=True)
|
|
43
|
-
raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {got}")
|
|
44
|
-
prov = provenance.record(dataset, asset, path)
|
|
45
|
-
provenance.write(prov, path)
|
|
46
|
-
return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=False)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def fetch(
|
|
50
|
-
dataset: Dataset, query: Query, *, root: Path | None = None, force: bool = False
|
|
51
|
-
) -> list[FetchedAsset]:
|
|
52
|
-
"""Resolve ``query`` against ``dataset`` and fetch everything it matches."""
|
|
53
|
-
assets = load_adapter(dataset).list_assets(query)
|
|
54
|
-
return [fetch_asset(dataset, a, root=root, force=force) for a in assets]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|