usdata 0.28.0__tar.gz → 0.29.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.28.0 → usdata-0.29.0}/PKG-INFO +1 -1
- {usdata-0.28.0 → usdata-0.29.0}/pyproject.toml +1 -1
- {usdata-0.28.0 → usdata-0.29.0}/pyproject.toml.orig +1 -1
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_fetch.py +34 -23
- usdata-0.29.0/src/usdata/_files.py +64 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_grib.py +52 -17
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cache_ops.py +10 -5
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/app.py +4 -1
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/places.csv +2 -2
- usdata-0.29.0/src/usdata/data/places.sources.json +44 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/registry.yaml +8 -4
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/inspect.py +40 -11
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/manifest.py +2 -2
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/models.py +16 -7
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/erddap.py +3 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/http.py +48 -17
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/base.py +3 -3
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/epa/aqs.py +52 -19
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/fema/declarations.py +4 -1
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/grib_index.py +39 -11
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/hrrr.py +2 -1
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/earthquakes.py +18 -7
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/pull.py +66 -14
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/query.py +10 -7
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/readers.py +28 -5
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/registry.py +11 -1
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/testing.py +4 -3
- usdata-0.28.0/src/usdata/_files.py +0 -29
- usdata-0.28.0/src/usdata/data/places.sources.json +0 -20
- {usdata-0.28.0 → usdata-0.29.0}/README.md +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/__main__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_aqs.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_progress.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_radar.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cache.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cite.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/cache.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/cite.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/doctor.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/inspect.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/doctor.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/mirror.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/provenance.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/credentials.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/epa/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/fema/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/http.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/coops.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gfs.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/glm.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/goes.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/mrms.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nbm.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nexrad.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/rap.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/spc.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/storm_events.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/params.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/daily.py +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/py.typed +0 -0
- {usdata-0.28.0 → usdata-0.29.0}/src/usdata/selection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usdata
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.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,open-data,scientific-data,provenance,reproducible-research,weather,climate,meteorology
|
|
6
6
|
Author: Jake Van Slyke
|
|
@@ -132,6 +132,29 @@ def _sidecar_not_older(path: Path) -> bool:
|
|
|
132
132
|
return False
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def _cached(dataset: Dataset, asset: Asset, path: Path) -> Provenance | None:
|
|
136
|
+
"""The record of a file at ``path`` that still describes ``asset``, or None if there is none."""
|
|
137
|
+
if not path.is_file():
|
|
138
|
+
return None
|
|
139
|
+
try:
|
|
140
|
+
prov = provenance.read(path)
|
|
141
|
+
except (ValueError, OSError):
|
|
142
|
+
return None
|
|
143
|
+
if (
|
|
144
|
+
prov.dataset_id == dataset.id
|
|
145
|
+
and prov.provider == dataset.provider
|
|
146
|
+
and prov.source_url == asset.href
|
|
147
|
+
and prov.size == path.stat().st_size
|
|
148
|
+
# A listing that now reports another size describes a rebuilt file.
|
|
149
|
+
and (asset.size is None or prov.size == asset.size)
|
|
150
|
+
and (asset.checksum is None or prov.checksum == asset.checksum)
|
|
151
|
+
# Trust an untouched cached file; hash whenever anything is unclear.
|
|
152
|
+
and (_sidecar_not_older(path) or sha256_file(path) == prov.checksum)
|
|
153
|
+
):
|
|
154
|
+
return prov
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
135
158
|
def _fetch_asset(
|
|
136
159
|
dataset: Dataset,
|
|
137
160
|
asset: Asset,
|
|
@@ -149,35 +172,23 @@ def _fetch_asset(
|
|
|
149
172
|
from the record rather than by resolving its query again.
|
|
150
173
|
|
|
151
174
|
``staging`` is a root a download is written under instead of the cache. The
|
|
152
|
-
cache at ``root`` is still checked, and a hit is returned from there;
|
|
153
|
-
|
|
154
|
-
|
|
175
|
+
cache at ``root`` is still checked, and a hit is returned from there; so is
|
|
176
|
+
``staging``, where an earlier source in the same run may have fetched the
|
|
177
|
+
same asset. A miss comes back with its ``path`` under ``staging``, for the
|
|
178
|
+
caller to move home once it can pin it (ADR 0031).
|
|
155
179
|
"""
|
|
156
180
|
if asset.dataset_id != dataset.id:
|
|
157
181
|
raise ValueError(f"asset dataset {asset.dataset_id!r} does not match {dataset.id!r}")
|
|
158
182
|
_progress.emit(_progress.AssetProgress(asset.id, "start", asset.size))
|
|
159
183
|
path = asset_path(asset, root)
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
prov = None
|
|
165
|
-
if (
|
|
166
|
-
prov is not None
|
|
167
|
-
and prov.dataset_id == dataset.id
|
|
168
|
-
and prov.provider == dataset.provider
|
|
169
|
-
and prov.source_url == asset.href
|
|
170
|
-
and prov.size == path.stat().st_size
|
|
171
|
-
# A listing that now reports another size describes a rebuilt file.
|
|
172
|
-
and (asset.size is None or prov.size == asset.size)
|
|
173
|
-
and (asset.checksum is None or prov.checksum == asset.checksum)
|
|
174
|
-
# Trust an untouched cached file; hash whenever anything is unclear.
|
|
175
|
-
and (_sidecar_not_older(path) or sha256_file(path) == prov.checksum)
|
|
176
|
-
):
|
|
184
|
+
staged = None if staging is None else asset_path(asset, staging)
|
|
185
|
+
# A staged run looks in its staging too, so an asset two sources share is fetched once.
|
|
186
|
+
for candidate in () if force else (path, staged):
|
|
187
|
+
if candidate is not None and (prov := _cached(dataset, asset, candidate)) is not None:
|
|
177
188
|
_progress.emit(_progress.AssetProgress(asset.id, "cached", prov.size))
|
|
178
|
-
return FetchedAsset(asset=asset, path=
|
|
179
|
-
if
|
|
180
|
-
path =
|
|
189
|
+
return FetchedAsset(asset=asset, path=candidate, provenance=prov, from_cache=True)
|
|
190
|
+
if staged is not None:
|
|
191
|
+
path = staged
|
|
181
192
|
with staged_path(path) as tmp:
|
|
182
193
|
partial = adapter.prepare_fetch(asset, pinned)
|
|
183
194
|
if partial is None:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Atomic replacement helpers for downloads and JSON records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import NamedTemporaryFile
|
|
10
|
+
|
|
11
|
+
TEMP_SUFFIX = ".part"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_temporary(path: Path) -> bool:
|
|
15
|
+
"""Whether ``path`` is a temporary file ``staged_path`` made, finished or abandoned."""
|
|
16
|
+
return path.name.startswith(".") and path.name.endswith(TEMP_SUFFIX)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def staged_path(dest: Path) -> Iterator[Path]:
|
|
21
|
+
"""Replace dest only after successful work in a unique sibling temporary file.
|
|
22
|
+
|
|
23
|
+
The file is flushed to disk before it replaces ``dest``, and the rename
|
|
24
|
+
after, so once this returns the new ``dest`` survives a crash; without that
|
|
25
|
+
a crash could keep the rename but lose the bytes, or reorder two renames.
|
|
26
|
+
"""
|
|
27
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with NamedTemporaryFile(
|
|
29
|
+
prefix=f".{dest.name}.", suffix=TEMP_SUFFIX, dir=dest.parent, delete=False
|
|
30
|
+
) as f:
|
|
31
|
+
tmp = Path(f.name)
|
|
32
|
+
try:
|
|
33
|
+
yield tmp
|
|
34
|
+
_fsync(tmp)
|
|
35
|
+
tmp.replace(dest)
|
|
36
|
+
_fsync_directory(dest.parent)
|
|
37
|
+
finally:
|
|
38
|
+
tmp.unlink(missing_ok=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def atomic_write_text(dest: Path, text: str) -> None:
|
|
42
|
+
"""Write UTF-8 text without exposing an incomplete record to readers."""
|
|
43
|
+
with staged_path(dest) as tmp:
|
|
44
|
+
tmp.write_text(text, encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fsync(path: Path) -> None:
|
|
48
|
+
# Windows flushes only through a descriptor opened for writing.
|
|
49
|
+
fd = os.open(path, os.O_RDWR)
|
|
50
|
+
try:
|
|
51
|
+
os.fsync(fd)
|
|
52
|
+
finally:
|
|
53
|
+
os.close(fd)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fsync_directory(path: Path) -> None:
|
|
57
|
+
"""Flush a directory's entries, where the platform allows opening a directory."""
|
|
58
|
+
if os.name == "nt":
|
|
59
|
+
return
|
|
60
|
+
fd = os.open(path, os.O_RDONLY)
|
|
61
|
+
try:
|
|
62
|
+
os.fsync(fd)
|
|
63
|
+
finally:
|
|
64
|
+
os.close(fd)
|
|
@@ -42,12 +42,19 @@ LIBRARY_HINT = (
|
|
|
42
42
|
"on macOS and on Windows with Python 3.14 install it separately, for example "
|
|
43
43
|
"`conda install -c conda-forge eccodes` or `brew install eccodes`"
|
|
44
44
|
)
|
|
45
|
-
MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)_\d{2}\.\d{2}_\d{8}-\d{6}\.grib2(?:\.gz)?$")
|
|
45
|
+
MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)(?:_\d{2}\.\d{2})?_\d{8}-\d{6}\.grib2(?:\.gz)?$")
|
|
46
|
+
"""An MRMS filename, whose product segment names a variable ecCodes cannot.
|
|
47
|
+
|
|
48
|
+
Most products end in a level such as ``_00.50``, which the name drops; one,
|
|
49
|
+
``LightningProbabilityNext30minGrid_scale_1``, has none and is named whole.
|
|
50
|
+
"""
|
|
46
51
|
INVENTORY_KEYS = ("shortName", "name", "typeOfLevel", "level", "step", "units")
|
|
47
|
-
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
"""
|
|
52
|
+
PARAMETER_KEYS = ("discipline", "parameterCategory", "parameterNumber")
|
|
53
|
+
"""The keys that identify a parameter ecCodes has no short name for."""
|
|
54
|
+
UNNAMED = frozenset({"", "unknown", "~"})
|
|
55
|
+
"""Short names ecCodes gives a parameter its tables do not name."""
|
|
56
|
+
SYMBOL_POWER = re.compile(r"(?<=[A-Za-z])\*\*(?=-?\d+(?![\d.]))")
|
|
57
|
+
"""An integer power of a unit symbol, as ecCodes writes ``kg**-1``."""
|
|
51
58
|
|
|
52
59
|
VARIABLE_KEYS = (
|
|
53
60
|
"name",
|
|
@@ -218,16 +225,23 @@ def _shape(eccodes: Any, h: int) -> tuple[int, int] | None:
|
|
|
218
225
|
return None if rows is None or cols is None else (rows, cols)
|
|
219
226
|
|
|
220
227
|
|
|
221
|
-
def inventory(path: Path) -> list[GribMessage]:
|
|
222
|
-
"""Every message in a local GRIB2 file, in file order, without decoding its values.
|
|
228
|
+
def inventory(path: Path, source: str | None = None) -> list[GribMessage]:
|
|
229
|
+
"""Every message in a local GRIB2 file, in file order, without decoding its values.
|
|
230
|
+
|
|
231
|
+
``source`` is the asset id the file was fetched as, which names a message
|
|
232
|
+
ecCodes cannot; it defaults to the file's name, which is the asset id for a
|
|
233
|
+
cached file.
|
|
234
|
+
"""
|
|
223
235
|
eccodes = _modules()[0]
|
|
224
236
|
messages = []
|
|
225
237
|
for index, h in _messages(eccodes, path):
|
|
226
238
|
keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
|
|
239
|
+
parameter = [_get(eccodes, h, key) for key in PARAMETER_KEYS]
|
|
227
240
|
messages.append(
|
|
228
241
|
GribMessage(
|
|
229
242
|
file_index=index,
|
|
230
243
|
short_name=keys["shortName"],
|
|
244
|
+
base_name=base_name(keys["shortName"], parameter, source or path.name),
|
|
231
245
|
name=keys["name"],
|
|
232
246
|
type_of_level=keys["typeOfLevel"],
|
|
233
247
|
level=keys["level"],
|
|
@@ -244,11 +258,13 @@ def udunits(units: str) -> str:
|
|
|
244
258
|
|
|
245
259
|
ecCodes writes powers as ``**``: ``J kg**-1``, ``m**2 s**-2``. UDUNITS and
|
|
246
260
|
CF write them as a trailing signed integer, ``J kg-1`` and ``m2 s-2``,
|
|
247
|
-
which is what xarray-based tools expect. Only the notation changes
|
|
248
|
-
of a
|
|
249
|
-
|
|
261
|
+
which is what xarray-based tools expect. Only the notation changes, and only
|
|
262
|
+
for an integer power of a unit symbol. Any other power has no such
|
|
263
|
+
spelling: of a number, as in ``10**-3``, of a group, as in ``(m s**-1)**2``,
|
|
264
|
+
or not an integer, as in ``m**(2/3)``. A string holding one is returned as
|
|
265
|
+
it is rather than half rewritten.
|
|
250
266
|
"""
|
|
251
|
-
if
|
|
267
|
+
if len(SYMBOL_POWER.findall(units)) != units.count("**"):
|
|
252
268
|
return units
|
|
253
269
|
return SYMBOL_POWER.sub("", units)
|
|
254
270
|
|
|
@@ -266,6 +282,28 @@ def _product_name(asset_id: str) -> str | None:
|
|
|
266
282
|
return match["product"] if match else None
|
|
267
283
|
|
|
268
284
|
|
|
285
|
+
def base_name(short_name: str | None, parameter: Sequence[Any], source: str) -> str:
|
|
286
|
+
"""The name one message's variable starts from, before ``variable_names`` qualifies it.
|
|
287
|
+
|
|
288
|
+
That is the ecCodes ``shortName`` where its tables name the parameter.
|
|
289
|
+
Where they do not, it is the MRMS product the asset id names, or else
|
|
290
|
+
``parameter_<discipline>_<category>_<number>``. The reader and
|
|
291
|
+
``Grib2Summary.variable_for`` both name messages through here.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
short_name: The message's ecCodes ``shortName``, or None when it has none.
|
|
295
|
+
parameter: Its ``discipline``, ``parameterCategory``, and ``parameterNumber``.
|
|
296
|
+
source: The asset id the file was fetched as.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The unqualified variable name.
|
|
300
|
+
"""
|
|
301
|
+
if short_name is not None and short_name not in UNNAMED:
|
|
302
|
+
return short_name
|
|
303
|
+
discipline, category, number = parameter
|
|
304
|
+
return _product_name(source) or f"parameter_{discipline}_{category}_{number}"
|
|
305
|
+
|
|
306
|
+
|
|
269
307
|
def _time(date: Any, time: Any) -> str | None:
|
|
270
308
|
if date is None or time is None:
|
|
271
309
|
return None
|
|
@@ -473,12 +511,9 @@ def open_grib2(
|
|
|
473
511
|
}
|
|
474
512
|
if selectors:
|
|
475
513
|
message["selector"] = selectors[index]
|
|
476
|
-
short =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
f"parameter_{attrs.get('discipline')}_{attrs.get('parameterCategory')}"
|
|
480
|
-
f"_{attrs.get('parameterNumber')}"
|
|
481
|
-
)
|
|
514
|
+
short = base_name(
|
|
515
|
+
message["shortName"], [attrs.get(key) for key in PARAMETER_KEYS], fetched.asset.id
|
|
516
|
+
)
|
|
482
517
|
selected.append(_Field(short=short, data=data, attrs=attrs, message=message))
|
|
483
518
|
if count > 1 and needs_select:
|
|
484
519
|
raise ValueError(
|
|
@@ -15,9 +15,10 @@ from pathlib import Path
|
|
|
15
15
|
from pydantic import BaseModel, Field, ValidationError
|
|
16
16
|
|
|
17
17
|
from usdata import provenance
|
|
18
|
+
from usdata._files import is_temporary
|
|
18
19
|
from usdata.cache import asset_path, cache_dir
|
|
19
20
|
from usdata.manifest import Lockfile, lockfile_path
|
|
20
|
-
from usdata.models import Provenance
|
|
21
|
+
from usdata.models import AwareUTC, Provenance, as_utc
|
|
21
22
|
from usdata.provenance import SIDECAR_SUFFIX
|
|
22
23
|
|
|
23
24
|
MANIFEST_GLOBS = ("*.yaml", "*.yml")
|
|
@@ -37,8 +38,8 @@ class CacheEntry(BaseModel):
|
|
|
37
38
|
asset_id: str
|
|
38
39
|
path: Path
|
|
39
40
|
size: int = Field(ge=0, description="Bytes of the data file, excluding its sidecar")
|
|
40
|
-
modified_at:
|
|
41
|
-
retrieved_at:
|
|
41
|
+
modified_at: AwareUTC = Field(description="Filesystem modification time, in UTC")
|
|
42
|
+
retrieved_at: AwareUTC | None = Field(
|
|
42
43
|
default=None,
|
|
43
44
|
description="When the sidecar says the file was fetched; unset without a readable sidecar",
|
|
44
45
|
)
|
|
@@ -90,6 +91,7 @@ def entries(root: Path | None = None) -> list[CacheEntry]:
|
|
|
90
91
|
One entry per file at ``<root>/<provider>/<name>/<asset id>``. A file
|
|
91
92
|
with no readable sidecar is still listed, as unrecorded, with its
|
|
92
93
|
dataset id taken from the directories it sits in. Sidecars themselves,
|
|
94
|
+
temporary files a download or a killed run left, the staging directory,
|
|
93
95
|
and files at any other depth, are skipped.
|
|
94
96
|
"""
|
|
95
97
|
base = _root(root)
|
|
@@ -98,9 +100,11 @@ def entries(root: Path | None = None) -> list[CacheEntry]:
|
|
|
98
100
|
found = [
|
|
99
101
|
_entry(path)
|
|
100
102
|
for provider in _subdirectories(base)
|
|
103
|
+
# No provider id starts with a dot; the staging directory does.
|
|
104
|
+
if not provider.name.startswith(".")
|
|
101
105
|
for name in _subdirectories(provider)
|
|
102
106
|
for path in sorted(name.iterdir())
|
|
103
|
-
if path.is_file() and not path.name.endswith(SIDECAR_SUFFIX)
|
|
107
|
+
if path.is_file() and not path.name.endswith(SIDECAR_SUFFIX) and not is_temporary(path)
|
|
104
108
|
]
|
|
105
109
|
return sorted(found, key=lambda entry: (entry.dataset_id, entry.asset_id))
|
|
106
110
|
|
|
@@ -131,11 +135,12 @@ def candidates(
|
|
|
131
135
|
older_than: Keep entries younger than this age. Unfiltered when None.
|
|
132
136
|
dataset: Keep only entries of this dataset id. Unfiltered when None.
|
|
133
137
|
now: The moment ages are measured from; the current UTC time when omitted.
|
|
138
|
+
A naive value means UTC.
|
|
134
139
|
|
|
135
140
|
Returns:
|
|
136
141
|
The matching entries, in the order :func:`entries` returns them.
|
|
137
142
|
"""
|
|
138
|
-
moment =
|
|
143
|
+
moment = datetime.now(UTC) if now is None else as_utc(now)
|
|
139
144
|
return [
|
|
140
145
|
entry
|
|
141
146
|
for entry in entries(root)
|
|
@@ -404,6 +404,8 @@ def fetch(
|
|
|
404
404
|
typer.echo(f"{len(assets)} asset(s) matched, {sizes}", err=True)
|
|
405
405
|
with progress(disabled=no_progress):
|
|
406
406
|
batch([asset.size for asset in assets])
|
|
407
|
+
if not assets:
|
|
408
|
+
raise typer.Exit(code=1)
|
|
407
409
|
return
|
|
408
410
|
with progress(disabled=no_progress):
|
|
409
411
|
fetched = fetch_query(ds, query, root=cache_dir, force=force)
|
|
@@ -507,7 +509,8 @@ def pull(
|
|
|
507
509
|
typer.echo(f"{d.asset_id}\t{d.problem}\t{d.path}")
|
|
508
510
|
typer.secho(
|
|
509
511
|
f"{len(e.drift)} asset(s) changed upstream; lockfile unchanged. "
|
|
510
|
-
"Pass --update <asset or dataset id> to accept the new bytes
|
|
512
|
+
"Pass --update <asset or dataset id> to accept the new bytes, "
|
|
513
|
+
"or --force to re-resolve.",
|
|
511
514
|
err=True,
|
|
512
515
|
fg="red",
|
|
513
516
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
kind,geoid,name,qualified_name,state,state_name,west,south,east,north
|
|
2
2
|
state,01,Alabama,Alabama,AL,Alabama,-88.473227,30.223334,-84.889080,35.008028
|
|
3
|
-
state,02,Alaska,Alaska,AK,Alaska,-179.146711,51.215567
|
|
3
|
+
state,02,Alaska,Alaska,AK,Alaska,-179.146711,51.215567,-129.974167,71.387815
|
|
4
4
|
state,04,Arizona,Arizona,AZ,Arizona,-114.816510,31.332177,-109.045223,37.004260
|
|
5
5
|
state,05,Arkansas,Arkansas,AR,Arkansas,-94.617919,33.004291,-89.641003,36.499600
|
|
6
6
|
state,06,California,California,CA,California,-124.409591,32.534435,-114.131211,42.009485
|
|
@@ -123,7 +123,7 @@ county,01129,Washington,Washington County,AL,Alabama,-88.464427,31.114298,-87.88
|
|
|
123
123
|
county,01131,Wilcox,Wilcox County,AL,Alabama,-87.667945,31.827123,-86.856576,32.268921
|
|
124
124
|
county,01133,Winston,Winston County,AL,Alabama,-87.636573,33.985004,-87.109911,34.306997
|
|
125
125
|
county,02013,Aleutians East,Aleutians East Borough,AK,Alaska,-166.199167,53.953959,-158.893471,56.880265
|
|
126
|
-
county,02016,Aleutians West,Aleutians West Census Area,AK,Alaska,-179.146711,51.215567
|
|
126
|
+
county,02016,Aleutians West,Aleutians West Census Area,AK,Alaska,-179.146711,51.215567,-166.038614,57.249626
|
|
127
127
|
county,02020,Anchorage,Anchorage Municipality,AK,Alaska,-150.285859,60.732987,-148.462403,61.484029
|
|
128
128
|
county,02050,Bethel,Bethel Census Area,AK,Alaska,-173.121318,58.550337,-153.001640,62.292644
|
|
129
129
|
county,02060,Bristol Bay,Bristol Bay Borough,AK,Alaska,-157.260883,58.615921,-156.316015,58.885114
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"vintage": "2025",
|
|
3
|
+
"scale": "1:500,000",
|
|
4
|
+
"coordinate_system": "WGS84 longitude/latitude",
|
|
5
|
+
"generator": "scripts/build_places.py",
|
|
6
|
+
"sources": [
|
|
7
|
+
{
|
|
8
|
+
"url": "https://www2.census.gov/geo/tiger/GENZ2025/kml/cb_2025_us_state_500k.zip",
|
|
9
|
+
"sha256": "4295a503ff47e6787adfb520ad6706557e41c2ce57ced5a134087f8e35143a02",
|
|
10
|
+
"records": 56
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"url": "https://www2.census.gov/geo/tiger/GENZ2025/kml/cb_2025_us_county_500k.zip",
|
|
14
|
+
"sha256": "fd8e3d57d59d43ab16692ba62a3c139705a899acd6ad4db3d66a05e3a21a54c6",
|
|
15
|
+
"records": 3235
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"csv_sha256": "eed6d7c53dcccb486f4e6f46fd393195cfb14e198b241f5ed9eed356f41f71f4",
|
|
19
|
+
"antimeridian": {
|
|
20
|
+
"rule": "A place whose longitudes span more than 180 degrees keeps only its western-hemisphere polygons; no box crosses the antimeridian.",
|
|
21
|
+
"clipped": [
|
|
22
|
+
{
|
|
23
|
+
"geoid": "02",
|
|
24
|
+
"dropped_polygons": 14,
|
|
25
|
+
"dropped_box": [
|
|
26
|
+
172.461667,
|
|
27
|
+
51.357688,
|
|
28
|
+
179.77847,
|
|
29
|
+
53.01075
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"geoid": "02016",
|
|
34
|
+
"dropped_polygons": 14,
|
|
35
|
+
"dropped_box": [
|
|
36
|
+
172.461667,
|
|
37
|
+
51.357688,
|
|
38
|
+
179.77847,
|
|
39
|
+
53.01075
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -79,12 +79,14 @@ datasets:
|
|
|
79
79
|
unidata-nexrad-level2 S3 bucket (NOAA Open Data Dissemination). One object per radar site
|
|
80
80
|
per volume scan. No server-side subsetting; whole files are fetched. Select radars by site
|
|
81
81
|
id, bbox, or nearest to a point. Moments vary by sweep and volume coverage pattern; the
|
|
82
|
-
catalog lists the ones the radar example's lowest sweep carries.
|
|
82
|
+
catalog lists the ones the radar example's lowest sweep carries. The extent spans the
|
|
83
|
+
radars from the Aleutians to the Azores; Guam, Korea, and Okinawa lie beyond the
|
|
84
|
+
antimeridian, outside it, and are reached by site id.
|
|
83
85
|
keywords: [radar, weather, storms, tornado, precipitation, reflectivity, nexrad, wsr-88d]
|
|
84
86
|
protocol: s3
|
|
85
87
|
homepage: https://registry.opendata.aws/noaa-nexrad/
|
|
86
88
|
license: US Government Work (public domain)
|
|
87
|
-
spatial_extent: { west: -180.0, south: 15.0, east: -
|
|
89
|
+
spatial_extent: { west: -180.0, south: 15.0, east: -27.0, north: 72.0 }
|
|
88
90
|
temporal_extent: { start: "1991-06-01T00:00:00Z" }
|
|
89
91
|
capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false }
|
|
90
92
|
summary: NEXRAD radar scans
|
|
@@ -905,12 +907,14 @@ datasets:
|
|
|
905
907
|
S3 bucket, with flat keys SITE_PRODUCT_YYYY_MM_DD_HH_MM_SS where the site
|
|
906
908
|
id drops the first letter of its ICAO id. One object per product per
|
|
907
909
|
volume scan since 2020-03-30; earlier products are archived at NCEI.
|
|
908
|
-
Files are fetched whole and have no reader.
|
|
910
|
+
Files are fetched whole and have no reader. The extent spans the radars
|
|
911
|
+
from the Aleutians to the Azores; Guam, Korea, and Okinawa lie beyond the
|
|
912
|
+
antimeridian, outside it, and are reached by site id.
|
|
909
913
|
keywords: [radar, nexrad, level iii, reflectivity, velocity, mesocyclone, storm tracks, products, tornado]
|
|
910
914
|
protocol: s3
|
|
911
915
|
homepage: https://registry.opendata.aws/noaa-nexrad/
|
|
912
916
|
license: US Government Work (public domain)
|
|
913
|
-
spatial_extent: { west: -180.0, south: 15.0, east: -
|
|
917
|
+
spatial_extent: { west: -180.0, south: 15.0, east: -27.0, north: 72.0 }
|
|
914
918
|
temporal_extent: { start: "2020-03-30T00:00:00Z" }
|
|
915
919
|
capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false }
|
|
916
920
|
summary: NEXRAD derived radar products
|
|
@@ -13,7 +13,6 @@ import csv
|
|
|
13
13
|
import gzip
|
|
14
14
|
import io
|
|
15
15
|
import os
|
|
16
|
-
from datetime import datetime
|
|
17
16
|
from enum import StrEnum
|
|
18
17
|
from pathlib import Path
|
|
19
18
|
from typing import TYPE_CHECKING
|
|
@@ -21,7 +20,7 @@ from typing import TYPE_CHECKING
|
|
|
21
20
|
from pydantic import BaseModel, Field
|
|
22
21
|
|
|
23
22
|
from usdata import provenance, readers
|
|
24
|
-
from usdata.models import Protocol, Provenance
|
|
23
|
+
from usdata.models import AwareUTC, Protocol, Provenance
|
|
25
24
|
|
|
26
25
|
if TYPE_CHECKING:
|
|
27
26
|
from usdata._fetch import FetchedAsset
|
|
@@ -99,6 +98,13 @@ class GribMessage(BaseModel):
|
|
|
99
98
|
default=None, description="Index selector this message was fetched for, for a partial fetch"
|
|
100
99
|
)
|
|
101
100
|
short_name: str | None = Field(default=None, description="ecCodes shortName")
|
|
101
|
+
base_name: str | None = Field(
|
|
102
|
+
default=None,
|
|
103
|
+
description=(
|
|
104
|
+
"Name the reader starts this message's variable from: shortName, or where "
|
|
105
|
+
"ecCodes has none, the MRMS product or parameter_<discipline>_<category>_<number>"
|
|
106
|
+
),
|
|
107
|
+
)
|
|
102
108
|
name: str | None = Field(default=None, description="ecCodes name")
|
|
103
109
|
type_of_level: str | None = Field(default=None, description="ecCodes typeOfLevel")
|
|
104
110
|
level: str | None = Field(default=None, description="ecCodes level")
|
|
@@ -130,18 +136,35 @@ class Grib2Summary(BaseModel):
|
|
|
130
136
|
Raises:
|
|
131
137
|
KeyError: No message here was fetched for that selector; the message
|
|
132
138
|
lists the selectors that were.
|
|
139
|
+
ValueError: That selector fetched a message holding several fields,
|
|
140
|
+
as RAP publishes wind components, and the record cannot say
|
|
141
|
+
which field it named; the message lists their variables.
|
|
133
142
|
"""
|
|
134
143
|
from usdata._grib import variable_names
|
|
135
144
|
|
|
136
145
|
names = variable_names(
|
|
137
146
|
[
|
|
138
|
-
(
|
|
147
|
+
(
|
|
148
|
+
message.base_name or message.short_name or "",
|
|
149
|
+
message.type_of_level,
|
|
150
|
+
message.level,
|
|
151
|
+
)
|
|
139
152
|
for message in self.messages
|
|
140
153
|
]
|
|
141
154
|
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
155
|
+
matched = [
|
|
156
|
+
name
|
|
157
|
+
for message, name in zip(self.messages, names, strict=True)
|
|
158
|
+
if message.selector == selector
|
|
159
|
+
]
|
|
160
|
+
if len(matched) > 1:
|
|
161
|
+
# The record keeps one selector per byte range, and one range can hold several fields.
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"selector {selector!r} fetched one message holding {len(matched)} fields "
|
|
164
|
+
f"({', '.join(matched)}); pick the variable by name"
|
|
165
|
+
)
|
|
166
|
+
if matched:
|
|
167
|
+
return matched[0]
|
|
145
168
|
recorded = ", ".join(
|
|
146
169
|
repr(message.selector) for message in self.messages if message.selector is not None
|
|
147
170
|
)
|
|
@@ -164,7 +187,7 @@ class Summary(BaseModel):
|
|
|
164
187
|
path: Path
|
|
165
188
|
size: int = Field(ge=0, description="Bytes on disk now, not the size provenance recorded")
|
|
166
189
|
format: AssetFormat
|
|
167
|
-
retrieved_at:
|
|
190
|
+
retrieved_at: AwareUTC
|
|
168
191
|
checksum: str
|
|
169
192
|
source_url: str
|
|
170
193
|
csv: CsvSummary | None = None
|
|
@@ -243,7 +266,7 @@ def _summarize(
|
|
|
243
266
|
note: str | None = None
|
|
244
267
|
if fmt is not AssetFormat.BYTES:
|
|
245
268
|
try:
|
|
246
|
-
detail = _detail(path, fmt, record, protocol)
|
|
269
|
+
detail = _detail(path, asset_id, fmt, record, protocol)
|
|
247
270
|
except readers.MissingReaderDependency as error:
|
|
248
271
|
note = str(error)
|
|
249
272
|
# A cached file that no longer decodes is reported, not raised: a summary
|
|
@@ -288,16 +311,22 @@ def _detect(name: str, media_type: str | None) -> AssetFormat:
|
|
|
288
311
|
|
|
289
312
|
|
|
290
313
|
def _detail(
|
|
291
|
-
path: Path, fmt: AssetFormat, record: Provenance, protocol: Protocol | None
|
|
314
|
+
path: Path, asset_id: str, fmt: AssetFormat, record: Provenance, protocol: Protocol | None
|
|
292
315
|
) -> CsvSummary | NetcdfSummary | Grib2Summary:
|
|
293
|
-
"""The detail for one recognized format, reading only what that format needs.
|
|
316
|
+
"""The detail for one recognized format, reading only what that format needs.
|
|
317
|
+
|
|
318
|
+
A GRIB2 inventory is given the asset id, which names a message ecCodes
|
|
319
|
+
cannot, as the reader names it.
|
|
320
|
+
"""
|
|
294
321
|
if fmt is AssetFormat.CSV:
|
|
295
322
|
return _csv_summary(path, units_row=readers.has_units_row(record.dataset_id, protocol))
|
|
296
323
|
if fmt is AssetFormat.NETCDF:
|
|
297
324
|
from usdata._netcdf import variables
|
|
298
325
|
|
|
299
326
|
return NetcdfSummary(variables=variables(path))
|
|
300
|
-
|
|
327
|
+
from usdata._grib import inventory
|
|
328
|
+
|
|
329
|
+
return Grib2Summary(messages=_paired(inventory(path, asset_id), record))
|
|
301
330
|
|
|
302
331
|
|
|
303
332
|
def _paired(messages: list[GribMessage], record: Provenance) -> list[GribMessage]:
|
|
@@ -12,7 +12,7 @@ import yaml
|
|
|
12
12
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
13
13
|
|
|
14
14
|
from usdata._files import atomic_write_text
|
|
15
|
-
from usdata.models import Asset, BBox, Provenance, Query
|
|
15
|
+
from usdata.models import Asset, AwareUTC, BBox, Provenance, Query
|
|
16
16
|
from usdata.query import build_query
|
|
17
17
|
from usdata.registry import Registry, default_registry
|
|
18
18
|
|
|
@@ -123,7 +123,7 @@ class Lockfile(BaseModel):
|
|
|
123
123
|
|
|
124
124
|
manifest: str
|
|
125
125
|
manifest_checksum: str = Field(description="sha256 of the manifest file when it was resolved")
|
|
126
|
-
generated_at:
|
|
126
|
+
generated_at: AwareUTC
|
|
127
127
|
usdata_version: str
|
|
128
128
|
assets: list[LockedAsset] = Field(default_factory=list)
|
|
129
129
|
|
|
@@ -9,12 +9,21 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import math
|
|
11
11
|
import re
|
|
12
|
-
from datetime import datetime, timedelta
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
13
|
from enum import StrEnum
|
|
14
14
|
from pathlib import PurePosixPath
|
|
15
|
-
from typing import Any, Literal
|
|
15
|
+
from typing import Annotated, Any, Literal
|
|
16
16
|
|
|
17
|
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
17
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def as_utc(value: datetime) -> datetime:
|
|
21
|
+
"""The shared time policy: a naive datetime means UTC, an aware one converts to UTC."""
|
|
22
|
+
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
AwareUTC = Annotated[datetime, AfterValidator(as_utc)]
|
|
26
|
+
"""A datetime field stored as aware UTC whatever it was given; see :func:`as_utc`."""
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
class Protocol(StrEnum):
|
|
@@ -128,8 +137,8 @@ class TimeRange(BaseModel):
|
|
|
128
137
|
|
|
129
138
|
model_config = ConfigDict(frozen=True)
|
|
130
139
|
|
|
131
|
-
start:
|
|
132
|
-
end:
|
|
140
|
+
start: AwareUTC | None = None
|
|
141
|
+
end: AwareUTC | None = None
|
|
133
142
|
|
|
134
143
|
@model_validator(mode="after")
|
|
135
144
|
def _ordered(self) -> TimeRange:
|
|
@@ -596,7 +605,7 @@ class TemporalSelection(BaseModel):
|
|
|
596
605
|
candidates. Counts refer to the supplied candidates, not a remote catalog.
|
|
597
606
|
"""
|
|
598
607
|
|
|
599
|
-
target:
|
|
608
|
+
target: AwareUTC
|
|
600
609
|
tolerance: timedelta
|
|
601
610
|
direction: Literal["nearest", "at_or_before"]
|
|
602
611
|
asset: Asset | None
|
|
@@ -611,7 +620,7 @@ class Provenance(BaseModel):
|
|
|
611
620
|
dataset_id: str
|
|
612
621
|
provider: str
|
|
613
622
|
source_url: str
|
|
614
|
-
retrieved_at:
|
|
623
|
+
retrieved_at: AwareUTC
|
|
615
624
|
checksum: str
|
|
616
625
|
size: int = Field(ge=0)
|
|
617
626
|
license: str | None = None
|
|
@@ -70,6 +70,9 @@ def axis(base: str, dataset: str, name: str, client: httpx.Client) -> tuple[str,
|
|
|
70
70
|
response = http.get(_endpoint(base, "griddap", dataset) + ".csv?" + _identifier(name), client)
|
|
71
71
|
try:
|
|
72
72
|
rows = list(csv.reader(io.StringIO(response.text)))
|
|
73
|
+
# A unitless axis has an empty units row, which reads as no fields at all.
|
|
74
|
+
if len(rows) >= 2 and rows[1] == []:
|
|
75
|
+
rows[1] = [""]
|
|
73
76
|
if len(rows) < 3 or rows[0] != [name] or any(len(row) != 1 for row in rows):
|
|
74
77
|
raise ValueError("invalid axis CSV")
|
|
75
78
|
return rows[1][0], [row[0] for row in rows[2:]]
|