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.
Files changed (31) hide show
  1. {usdata-0.3.0 → usdata-0.4.0}/PKG-INFO +7 -4
  2. {usdata-0.3.0 → usdata-0.4.0}/README.md +6 -3
  3. {usdata-0.3.0 → usdata-0.4.0}/pyproject.toml +1 -1
  4. {usdata-0.3.0 → usdata-0.4.0}/pyproject.toml.orig +1 -1
  5. usdata-0.4.0/src/usdata/_files.py +29 -0
  6. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cache.py +14 -2
  7. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cli/app.py +14 -9
  8. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/registry.yaml +7 -7
  9. usdata-0.4.0/src/usdata/fetch.py +79 -0
  10. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/manifest.py +30 -3
  11. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/http.py +2 -7
  12. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/provenance.py +2 -1
  13. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/base.py +17 -0
  14. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/coastwatch.py +1 -1
  15. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/ghcnd.py +7 -0
  16. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/nexrad.py +11 -1
  17. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/pull.py +22 -15
  18. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/registry.py +2 -1
  19. usdata-0.3.0/src/usdata/fetch.py +0 -54
  20. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/__init__.py +0 -0
  21. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/cli/__init__.py +0 -0
  22. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/nexrad_sites.csv +0 -0
  23. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/data/places.yaml +0 -0
  24. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/models.py +0 -0
  25. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/__init__.py +0 -0
  26. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/protocols/s3.py +0 -0
  27. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/__init__.py +0 -0
  28. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/__init__.py +0 -0
  29. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/providers/noaa/sites.py +0 -0
  30. {usdata-0.3.0 → usdata-0.4.0}/src/usdata/py.typed +0 -0
  31. {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.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.3) | Datasets |
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 | — | +3 planned |
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.3) | Datasets |
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 | — | +3 planned |
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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.split(":", 1)
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
- return (root or cache_dir()) / provider / name / safe_id
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, fetch_asset
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 UnknownPlace as e:
63
- typer.secho(f"Unknown place: {e}", err=True, fg="red")
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 = [fetch_asset(ds, a, root=cache_dir, force=force) for a in assets]
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
- drift = verify_manifest(manifest, root=cache_dir)
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.3"
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.4"
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.4"
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.4"
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.4"
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.4"
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.4"
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
- return cls.model_validate(yaml.safe_load(path.read_text()) or {})
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.write_text(self.model_dump_json(indent=2))
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.write_text(prov.model_dump_json(indent=2))
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."""
@@ -1,4 +1,4 @@
1
- """CoastWatch SST via ERDDAP. Registered in the registry; implementation is planned for v0.1."""
1
+ """CoastWatch SST via ERDDAP. Registered in the registry; implementation is planned for v0.5."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -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
- sites.get_site(sid) # validate
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, fetch_asset
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 asset in load_adapter(dataset).list_assets(source.to_query()):
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
- for entry in lock.assets:
101
- dataset = reg.get(entry.asset.dataset_id)
102
- path = asset_path(entry.asset, root)
103
- if path.exists() and sha256_file(path) == entry.provenance.checksum:
104
- fetched.append(
105
- FetchedAsset(
106
- asset=entry.asset, path=path, provenance=entry.provenance, from_cache=True
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 self._domains and ds.domain not in self._domains:
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
 
@@ -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