usdata 0.2.0__tar.gz → 0.2.1__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.2.0 → usdata-0.2.1}/PKG-INFO +7 -5
- {usdata-0.2.0 → usdata-0.2.1}/README.md +6 -4
- {usdata-0.2.0 → usdata-0.2.1}/pyproject.toml +14 -1
- {usdata-0.2.0 → usdata-0.2.1}/pyproject.toml.orig +12 -2
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/cache.py +3 -0
- usdata-0.2.1/src/usdata/cli/__init__.py +5 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/cli/app.py +3 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/fetch.py +4 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/manifest.py +11 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/models.py +5 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/http.py +2 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/s3.py +4 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/provenance.py +3 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/base.py +3 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/coastwatch.py +4 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/ghcnd.py +4 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/nexrad.py +5 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/sites.py +7 -0
- usdata-0.2.1/src/usdata/py.typed +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/query.py +2 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/registry.py +11 -0
- usdata-0.2.0/src/usdata/cli/__init__.py +0 -3
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/__init__.py +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/places.yaml +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/data/registry.yaml +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.2.0 → usdata-0.2.1}/src/usdata/providers/noaa/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usdata
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -102,11 +102,13 @@ just run search radar
|
|
|
102
102
|
|
|
103
103
|
Integration tests that hit live services run with `just test-integration`.
|
|
104
104
|
|
|
105
|
-
Releases
|
|
106
|
-
|
|
105
|
+
Releases: `just release minor` opens a version-bump PR; merging it publishes
|
|
106
|
+
to PyPI and creates the tag and GitHub release. See
|
|
107
|
+
[docs/versioning.md](docs/versioning.md).
|
|
107
108
|
|
|
108
|
-
See [docs/architecture.md](docs/architecture.md) for how the pieces fit
|
|
109
|
-
[docs/adr/](docs/adr/) for why.
|
|
109
|
+
See [docs/architecture.md](docs/architecture.md) for how the pieces fit,
|
|
110
|
+
[docs/adr/](docs/adr/) for why, and [CONTRIBUTING.md](CONTRIBUTING.md) to add
|
|
111
|
+
a dataset.
|
|
110
112
|
|
|
111
113
|
## License
|
|
112
114
|
|
|
@@ -81,11 +81,13 @@ just run search radar
|
|
|
81
81
|
|
|
82
82
|
Integration tests that hit live services run with `just test-integration`.
|
|
83
83
|
|
|
84
|
-
Releases
|
|
85
|
-
|
|
84
|
+
Releases: `just release minor` opens a version-bump PR; merging it publishes
|
|
85
|
+
to PyPI and creates the tag and GitHub release. See
|
|
86
|
+
[docs/versioning.md](docs/versioning.md).
|
|
86
87
|
|
|
87
|
-
See [docs/architecture.md](docs/architecture.md) for how the pieces fit
|
|
88
|
-
[docs/adr/](docs/adr/) for why.
|
|
88
|
+
See [docs/architecture.md](docs/architecture.md) for how the pieces fit,
|
|
89
|
+
[docs/adr/](docs/adr/) for why, and [CONTRIBUTING.md](CONTRIBUTING.md) to add
|
|
90
|
+
a dataset.
|
|
89
91
|
|
|
90
92
|
## License
|
|
91
93
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "usdata"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
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"
|
|
@@ -41,6 +41,7 @@ usdata = "usdata.cli:app"
|
|
|
41
41
|
dev = [
|
|
42
42
|
"pyright>=1.1.380",
|
|
43
43
|
"pytest>=8.0",
|
|
44
|
+
"pytest-cov>=7.1.0",
|
|
44
45
|
"respx>=0.23.1",
|
|
45
46
|
"ruff>=0.6",
|
|
46
47
|
]
|
|
@@ -66,7 +67,19 @@ select = [
|
|
|
66
67
|
"B",
|
|
67
68
|
"SIM",
|
|
68
69
|
"RUF",
|
|
70
|
+
"D",
|
|
69
71
|
]
|
|
72
|
+
ignore = [
|
|
73
|
+
"D105",
|
|
74
|
+
"D107",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.pydocstyle]
|
|
78
|
+
convention = "google"
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.per-file-ignores]
|
|
81
|
+
"tests/**" = ["D"]
|
|
82
|
+
"scripts/**" = ["D"]
|
|
70
83
|
|
|
71
84
|
[tool.pyright]
|
|
72
85
|
include = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "usdata"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
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"
|
|
@@ -33,6 +33,7 @@ usdata = "usdata.cli:app"
|
|
|
33
33
|
dev = [
|
|
34
34
|
"pyright>=1.1.380",
|
|
35
35
|
"pytest>=8.0",
|
|
36
|
+
"pytest-cov>=7.1.0",
|
|
36
37
|
"respx>=0.23.1",
|
|
37
38
|
"ruff>=0.6",
|
|
38
39
|
]
|
|
@@ -47,7 +48,16 @@ target-version = "py311"
|
|
|
47
48
|
src = ["src", "tests"]
|
|
48
49
|
|
|
49
50
|
[tool.ruff.lint]
|
|
50
|
-
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
51
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "D"]
|
|
52
|
+
# Docstrings are required on the public API only; magic methods and __init__ are exempt.
|
|
53
|
+
ignore = ["D105", "D107"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.pydocstyle]
|
|
56
|
+
convention = "google"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
"tests/**" = ["D"]
|
|
60
|
+
"scripts/**" = ["D"]
|
|
51
61
|
|
|
52
62
|
[tool.pyright]
|
|
53
63
|
include = ["src", "tests"]
|
|
@@ -12,6 +12,7 @@ ENV_VAR = "USDATA_CACHE_DIR"
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def cache_dir() -> Path:
|
|
15
|
+
"""Cache root: $USDATA_CACHE_DIR, else $XDG_CACHE_HOME/usdata, else ~/.cache/usdata."""
|
|
15
16
|
if override := os.environ.get(ENV_VAR):
|
|
16
17
|
return Path(override).expanduser()
|
|
17
18
|
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
@@ -20,12 +21,14 @@ def cache_dir() -> Path:
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def asset_path(asset: Asset, root: Path | None = None) -> Path:
|
|
24
|
+
"""Where an asset lives in the cache: <root>/<provider>/<dataset>/<asset id>."""
|
|
23
25
|
provider, name = asset.dataset_id.split(":", 1)
|
|
24
26
|
safe_id = asset.id.replace("/", "_")
|
|
25
27
|
return (root or cache_dir()) / provider / name / safe_id
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def sha256_file(path: Path, chunk_size: int = 1 << 20) -> str:
|
|
31
|
+
"""Hex sha256 of a file, prefixed 'sha256:' to match Asset.checksum."""
|
|
29
32
|
h = hashlib.sha256()
|
|
30
33
|
with path.open("rb") as f:
|
|
31
34
|
while chunk := f.read(chunk_size):
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Typer application: argument parsing and exit codes only; logic lives in the library."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
from pathlib import Path
|
|
@@ -34,6 +36,7 @@ def main(
|
|
|
34
36
|
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
|
|
35
37
|
] = None,
|
|
36
38
|
) -> None:
|
|
39
|
+
"""usdata: discover, fetch, and track provenance of U.S. public scientific data."""
|
|
37
40
|
pass
|
|
38
41
|
|
|
39
42
|
|
|
@@ -13,10 +13,14 @@ from usdata.providers import load_adapter
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class ChecksumMismatch(RuntimeError):
|
|
16
|
+
"""A fetched file's sha256 did not match the checksum the adapter declared."""
|
|
17
|
+
|
|
16
18
|
pass
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class FetchedAsset(BaseModel):
|
|
22
|
+
"""One asset on disk with its provenance and whether the cache satisfied it."""
|
|
23
|
+
|
|
20
24
|
asset: Asset
|
|
21
25
|
path: Path
|
|
22
26
|
provenance: Provenance
|
|
@@ -26,6 +26,7 @@ class SourceSpec(BaseModel):
|
|
|
26
26
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
27
27
|
|
|
28
28
|
def to_query(self) -> Query:
|
|
29
|
+
"""Build the Query this source resolves to."""
|
|
29
30
|
return build_query(
|
|
30
31
|
location=self.location,
|
|
31
32
|
bbox=self.bbox,
|
|
@@ -37,12 +38,15 @@ class SourceSpec(BaseModel):
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
class Manifest(BaseModel):
|
|
41
|
+
"""A declarative list of inputs a project needs: usdata pull fetches them."""
|
|
42
|
+
|
|
40
43
|
name: str
|
|
41
44
|
version: str = "1.0"
|
|
42
45
|
sources: list[SourceSpec] = Field(min_length=1)
|
|
43
46
|
|
|
44
47
|
@classmethod
|
|
45
48
|
def load(cls, path: Path) -> Manifest:
|
|
49
|
+
"""Parse a manifest YAML file."""
|
|
46
50
|
return cls.model_validate(yaml.safe_load(path.read_text()) or {})
|
|
47
51
|
|
|
48
52
|
def validate_against(self, registry: Registry | None = None) -> list[str]:
|
|
@@ -52,22 +56,29 @@ class Manifest(BaseModel):
|
|
|
52
56
|
|
|
53
57
|
|
|
54
58
|
class LockedAsset(BaseModel):
|
|
59
|
+
"""One resolved asset and the provenance of the copy that was fetched."""
|
|
60
|
+
|
|
55
61
|
asset: Asset
|
|
56
62
|
provenance: Provenance
|
|
57
63
|
|
|
58
64
|
|
|
59
65
|
class Lockfile(BaseModel):
|
|
66
|
+
"""Exactly what a manifest resolved to, with checksums, so it can be reproduced."""
|
|
67
|
+
|
|
60
68
|
manifest: str
|
|
61
69
|
generated_at: datetime
|
|
62
70
|
assets: list[LockedAsset] = Field(default_factory=list)
|
|
63
71
|
|
|
64
72
|
@classmethod
|
|
65
73
|
def load(cls, path: Path) -> Lockfile:
|
|
74
|
+
"""Read a lockfile written by ``save``."""
|
|
66
75
|
return cls.model_validate_json(path.read_text())
|
|
67
76
|
|
|
68
77
|
def save(self, path: Path) -> None:
|
|
78
|
+
"""Write the lockfile as indented JSON."""
|
|
69
79
|
path.write_text(self.model_dump_json(indent=2))
|
|
70
80
|
|
|
71
81
|
|
|
72
82
|
def lockfile_path(manifest_path: Path) -> Path:
|
|
83
|
+
"""The lockfile that pairs with a manifest: <manifest stem>.lock.json."""
|
|
73
84
|
return manifest_path.with_suffix(".lock.json")
|
|
@@ -56,6 +56,7 @@ class BBox(BaseModel):
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
def intersects(self, other: BBox) -> bool:
|
|
59
|
+
"""True if the boxes share any area, edges included."""
|
|
59
60
|
return not (
|
|
60
61
|
other.west > self.east
|
|
61
62
|
or other.east < self.west
|
|
@@ -64,9 +65,11 @@ class BBox(BaseModel):
|
|
|
64
65
|
)
|
|
65
66
|
|
|
66
67
|
def contains_point(self, lat: float, lon: float) -> bool:
|
|
68
|
+
"""True if the point lies inside or on the edge of the box."""
|
|
67
69
|
return self.west <= lon <= self.east and self.south <= lat <= self.north
|
|
68
70
|
|
|
69
71
|
def as_tuple(self) -> tuple[float, float, float, float]:
|
|
72
|
+
"""The box as (west, south, east, north)."""
|
|
70
73
|
return (self.west, self.south, self.east, self.north)
|
|
71
74
|
|
|
72
75
|
|
|
@@ -85,6 +88,7 @@ class TimeRange(BaseModel):
|
|
|
85
88
|
return self
|
|
86
89
|
|
|
87
90
|
def overlaps(self, other: TimeRange) -> bool:
|
|
91
|
+
"""True if the ranges share any instant; open bounds match everything on that side."""
|
|
88
92
|
starts_after = self.start is not None and other.end is not None and self.start > other.end
|
|
89
93
|
ends_before = self.end is not None and other.start is not None and self.end < other.start
|
|
90
94
|
return not (starts_after or ends_before)
|
|
@@ -125,6 +129,7 @@ class Dataset(BaseModel):
|
|
|
125
129
|
|
|
126
130
|
@property
|
|
127
131
|
def name(self) -> str:
|
|
132
|
+
"""The dataset name without the provider prefix."""
|
|
128
133
|
return self.id.split(":", 1)[1]
|
|
129
134
|
|
|
130
135
|
|
|
@@ -21,6 +21,8 @@ NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class S3Object(BaseModel):
|
|
24
|
+
"""One key from a bucket listing."""
|
|
25
|
+
|
|
24
26
|
key: str
|
|
25
27
|
size: int
|
|
26
28
|
etag: str | None = None
|
|
@@ -28,6 +30,7 @@ class S3Object(BaseModel):
|
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
def parse_s3_url(url: str) -> tuple[str, str]:
|
|
33
|
+
"""Split 's3://bucket/key' into (bucket, key)."""
|
|
31
34
|
if not url.startswith("s3://"):
|
|
32
35
|
raise ValueError(f"not an s3:// URL: {url}")
|
|
33
36
|
bucket, _, key = url[5:].partition("/")
|
|
@@ -35,6 +38,7 @@ def parse_s3_url(url: str) -> tuple[str, str]:
|
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def https_url(bucket: str, key: str = "") -> str:
|
|
41
|
+
"""The virtual-hosted HTTPS URL for a bucket key, with the key percent-encoded."""
|
|
38
42
|
return f"https://{bucket}.s3.amazonaws.com/{quote(key)}"
|
|
39
43
|
|
|
40
44
|
|
|
@@ -27,14 +27,17 @@ def record(dataset: Dataset, asset: Asset, path: Path) -> Provenance:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def sidecar_path(path: Path) -> Path:
|
|
30
|
+
"""The provenance JSON file that sits beside a cached file."""
|
|
30
31
|
return path.with_name(path.name + SIDECAR_SUFFIX)
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
def write(prov: Provenance, path: Path) -> Path:
|
|
35
|
+
"""Write a provenance record beside ``path`` and return the sidecar path."""
|
|
34
36
|
out = sidecar_path(path)
|
|
35
37
|
out.write_text(prov.model_dump_json(indent=2))
|
|
36
38
|
return out
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def read(path: Path) -> Provenance:
|
|
42
|
+
"""Load the provenance record stored beside ``path``."""
|
|
40
43
|
return Provenance.model_validate_json(sidecar_path(path).read_text())
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""The Provider interface every dataset adapter implements, and how adapters are loaded."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import importlib
|
|
@@ -31,6 +33,7 @@ class Provider(ABC):
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def load_adapter(dataset: Dataset) -> Provider:
|
|
36
|
+
"""Instantiate the Provider named by a dataset's ``adapter`` dotted path."""
|
|
34
37
|
module_name, _, class_name = dataset.adapter.partition(":")
|
|
35
38
|
module = importlib.import_module(module_name)
|
|
36
39
|
cls = getattr(module, class_name)
|
|
@@ -9,8 +9,12 @@ from usdata.providers.base import NotImplementedProvider, Provider
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class CoastwatchSst(Provider):
|
|
12
|
+
"""Placeholder adapter; see issue #5."""
|
|
13
|
+
|
|
12
14
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
15
|
+
"""Not implemented yet."""
|
|
13
16
|
raise NotImplementedProvider(f"{self.dataset.id} adapter is not implemented yet")
|
|
14
17
|
|
|
15
18
|
def fetch(self, asset: Asset, dest: Path) -> Path:
|
|
19
|
+
"""Not implemented yet."""
|
|
16
20
|
raise NotImplementedProvider(f"{self.dataset.id} adapter is not implemented yet")
|
|
@@ -39,6 +39,8 @@ def _stations_param(raw: Any) -> list[str]:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class GhcnDaily(Provider):
|
|
42
|
+
"""GHCN-Daily adapter. Params: ``stations`` (list or comma string), ``units``."""
|
|
43
|
+
|
|
42
44
|
def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
|
|
43
45
|
super().__init__(dataset)
|
|
44
46
|
self._client = client
|
|
@@ -83,6 +85,7 @@ class GhcnDaily(Provider):
|
|
|
83
85
|
return found
|
|
84
86
|
|
|
85
87
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
88
|
+
"""One CSV asset per chunk of up to STATIONS_PER_ASSET stations for the query window."""
|
|
86
89
|
if query.time is None or query.time.start is None or query.time.end is None:
|
|
87
90
|
raise QueryError(f"{self.dataset.id} requires both start and end dates")
|
|
88
91
|
if "stations" in query.params:
|
|
@@ -125,4 +128,5 @@ class GhcnDaily(Provider):
|
|
|
125
128
|
return assets
|
|
126
129
|
|
|
127
130
|
def fetch(self, asset: Asset, dest: Path) -> Path:
|
|
131
|
+
"""Stream the CSV response to ``dest``."""
|
|
128
132
|
return http.download(asset.href, dest, self._http())
|
|
@@ -42,6 +42,8 @@ def scan_time(key: str) -> datetime | None:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class NexradLevel2(Provider):
|
|
45
|
+
"""NEXRAD Level II adapter. Params: ``site``/``sites`` (ICAO ids), ``nearest`` (int)."""
|
|
46
|
+
|
|
45
47
|
def __init__(self, dataset: Dataset, client: httpx.Client | None = None) -> None:
|
|
46
48
|
super().__init__(dataset)
|
|
47
49
|
self._client = client
|
|
@@ -52,6 +54,7 @@ class NexradLevel2(Provider):
|
|
|
52
54
|
return self._client
|
|
53
55
|
|
|
54
56
|
def select_sites(self, query: Query) -> list[str]:
|
|
57
|
+
"""Radar site ids the query refers to; see the module docstring for the rules."""
|
|
55
58
|
raw = query.params.get("sites", query.params.get("site"))
|
|
56
59
|
if raw is not None:
|
|
57
60
|
ids = _sites_param(raw)
|
|
@@ -70,6 +73,7 @@ class NexradLevel2(Provider):
|
|
|
70
73
|
return [s.id for s in sites.nearest(lat, lon, 1)]
|
|
71
74
|
|
|
72
75
|
def list_assets(self, query: Query) -> list[Asset]:
|
|
76
|
+
"""Every volume scan for the selected sites inside the query's UTC time window."""
|
|
73
77
|
if query.time is None or query.time.start is None or query.time.end is None:
|
|
74
78
|
raise QueryError(f"{self.dataset.id} requires both start and end times")
|
|
75
79
|
start = query.time.start.astimezone(UTC)
|
|
@@ -99,4 +103,5 @@ class NexradLevel2(Provider):
|
|
|
99
103
|
return assets
|
|
100
104
|
|
|
101
105
|
def fetch(self, asset: Asset, dest: Path) -> Path:
|
|
106
|
+
"""Download one scan object anonymously to ``dest``."""
|
|
102
107
|
return s3.download(asset.href, dest, self._http())
|
|
@@ -15,6 +15,8 @@ EARTH_RADIUS_KM = 6371.0
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class RadarSite(BaseModel):
|
|
18
|
+
"""A radar site from the bundled table. ``type`` is NEXRAD, TDWR, or TEST."""
|
|
19
|
+
|
|
18
20
|
model_config = ConfigDict(frozen=True)
|
|
19
21
|
|
|
20
22
|
id: str
|
|
@@ -26,6 +28,7 @@ class RadarSite(BaseModel):
|
|
|
26
28
|
type: str
|
|
27
29
|
|
|
28
30
|
def distance_km(self, lat: float, lon: float) -> float:
|
|
31
|
+
"""Great-circle distance from this site to a point, in kilometres."""
|
|
29
32
|
p1, p2 = math.radians(self.lat), math.radians(lat)
|
|
30
33
|
dphi = p2 - p1
|
|
31
34
|
dlam = math.radians(lon - self.lon)
|
|
@@ -35,6 +38,7 @@ class RadarSite(BaseModel):
|
|
|
35
38
|
|
|
36
39
|
@lru_cache(maxsize=1)
|
|
37
40
|
def all_sites() -> dict[str, RadarSite]:
|
|
41
|
+
"""The bundled site table keyed by ICAO id."""
|
|
38
42
|
text = (resources.files("usdata.data") / "nexrad_sites.csv").read_text()
|
|
39
43
|
sites = {}
|
|
40
44
|
for row in csv.DictReader(text.splitlines()):
|
|
@@ -45,6 +49,7 @@ def all_sites() -> dict[str, RadarSite]:
|
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
def get_site(site_id: str) -> RadarSite:
|
|
52
|
+
"""Look up a site by ICAO id, case-insensitively."""
|
|
48
53
|
try:
|
|
49
54
|
return all_sites()[site_id.upper()]
|
|
50
55
|
except KeyError:
|
|
@@ -52,6 +57,7 @@ def get_site(site_id: str) -> RadarSite:
|
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
def sites_in(bbox: BBox, *, types: tuple[str, ...] = ("NEXRAD",)) -> list[RadarSite]:
|
|
60
|
+
"""Sites of the given types whose location lies inside ``bbox``, sorted by id."""
|
|
55
61
|
return sorted(
|
|
56
62
|
(s for s in all_sites().values() if s.type in types and bbox.contains_point(s.lat, s.lon)),
|
|
57
63
|
key=lambda s: s.id,
|
|
@@ -61,5 +67,6 @@ def sites_in(bbox: BBox, *, types: tuple[str, ...] = ("NEXRAD",)) -> list[RadarS
|
|
|
61
67
|
def nearest(
|
|
62
68
|
lat: float, lon: float, n: int = 1, *, types: tuple[str, ...] = ("NEXRAD",)
|
|
63
69
|
) -> list[RadarSite]:
|
|
70
|
+
"""The ``n`` closest sites of the given types to a point, nearest first."""
|
|
64
71
|
candidates = [s for s in all_sites().values() if s.type in types]
|
|
65
72
|
return sorted(candidates, key=lambda s: s.distance_km(lat, lon))[:n]
|
|
File without changes
|
|
@@ -21,10 +21,14 @@ _TOKEN = re.compile(r"[a-z0-9]+")
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class DatasetNotFound(KeyError):
|
|
24
|
+
"""No registry entry has the requested id."""
|
|
25
|
+
|
|
24
26
|
pass
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
class SearchResult(BaseModel):
|
|
30
|
+
"""A dataset and its keyword-match score."""
|
|
31
|
+
|
|
28
32
|
model_config = ConfigDict(frozen=True)
|
|
29
33
|
|
|
30
34
|
dataset: Dataset
|
|
@@ -56,6 +60,8 @@ def _score(dataset: Dataset, terms: set[str]) -> float:
|
|
|
56
60
|
|
|
57
61
|
|
|
58
62
|
class Registry:
|
|
63
|
+
"""An in-memory collection of datasets addressable by id and searchable by keyword."""
|
|
64
|
+
|
|
59
65
|
def __init__(self, datasets: Iterable[Dataset]) -> None:
|
|
60
66
|
self._by_id: dict[str, Dataset] = {}
|
|
61
67
|
for ds in datasets:
|
|
@@ -65,11 +71,13 @@ class Registry:
|
|
|
65
71
|
|
|
66
72
|
@classmethod
|
|
67
73
|
def from_yaml(cls, path: Path) -> Registry:
|
|
74
|
+
"""Load a registry from a YAML file with a top-level ``datasets`` list."""
|
|
68
75
|
raw = yaml.safe_load(path.read_text()) or {}
|
|
69
76
|
return cls(Dataset.model_validate(d) for d in raw.get("datasets", []))
|
|
70
77
|
|
|
71
78
|
@classmethod
|
|
72
79
|
def bundled(cls) -> Registry:
|
|
80
|
+
"""The registry shipped inside the package."""
|
|
73
81
|
with resources.as_file(resources.files("usdata.data") / "registry.yaml") as p:
|
|
74
82
|
return cls.from_yaml(p)
|
|
75
83
|
|
|
@@ -83,12 +91,14 @@ class Registry:
|
|
|
83
91
|
return dataset_id in self._by_id
|
|
84
92
|
|
|
85
93
|
def get(self, dataset_id: str) -> Dataset:
|
|
94
|
+
"""Return the dataset with this id or raise DatasetNotFound."""
|
|
86
95
|
try:
|
|
87
96
|
return self._by_id[dataset_id]
|
|
88
97
|
except KeyError:
|
|
89
98
|
raise DatasetNotFound(dataset_id) from None
|
|
90
99
|
|
|
91
100
|
def providers(self) -> set[str]:
|
|
101
|
+
"""The set of provider names present in the registry."""
|
|
92
102
|
return {ds.provider for ds in self}
|
|
93
103
|
|
|
94
104
|
def search(self, query: Query) -> list[SearchResult]:
|
|
@@ -111,4 +121,5 @@ class Registry:
|
|
|
111
121
|
|
|
112
122
|
@lru_cache(maxsize=1)
|
|
113
123
|
def default_registry() -> Registry:
|
|
124
|
+
"""The bundled registry, loaded once per process."""
|
|
114
125
|
return Registry.bundled()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|