usdata 0.28.0__tar.gz → 0.30.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.30.0}/PKG-INFO +1 -1
- {usdata-0.28.0 → usdata-0.30.0}/pyproject.toml +1 -1
- {usdata-0.28.0 → usdata-0.30.0}/pyproject.toml.orig +1 -1
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_fetch.py +47 -23
- usdata-0.30.0/src/usdata/_files.py +64 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_grib.py +64 -24
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cache.py +14 -5
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cache_ops.py +10 -5
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/app.py +4 -1
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/places.csv +3300 -3292
- usdata-0.30.0/src/usdata/data/places.sources.json +60 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/registry.yaml +8 -4
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/inspect.py +40 -11
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/manifest.py +80 -9
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/models.py +36 -11
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/erddap.py +3 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/http.py +48 -17
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/provenance.py +1 -3
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/base.py +25 -3
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/epa/aqs.py +56 -20
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/fema/declarations.py +10 -1
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/coastwatch.py +5 -5
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/grib_index.py +39 -11
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/hrrr.py +2 -1
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nws_vtec.py +4 -1
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/earthquakes.py +18 -7
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/pull.py +106 -25
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/query.py +46 -12
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/readers.py +34 -11
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/registry.py +11 -1
- {usdata-0.28.0 → usdata-0.30.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.30.0}/README.md +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/__main__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_aqs.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_progress.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_radar.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cite.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/cache.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/cite.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/doctor.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/inspect.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/doctor.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/mirror.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/s3.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/credentials.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/epa/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/fema/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/http.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/coops.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gfs.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/glm.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/goes.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/mrms.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nbm.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nexrad.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/rap.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/spc.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/storm_events.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/params.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/daily.py +0 -0
- {usdata-0.28.0 → usdata-0.30.0}/src/usdata/py.typed +0 -0
- {usdata-0.28.0 → usdata-0.30.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.30.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:
|
|
@@ -223,6 +234,19 @@ def _fetch_with(
|
|
|
223
234
|
``staging`` is passed to each fetch; see ``_fetch_asset``.
|
|
224
235
|
"""
|
|
225
236
|
assets = ordered(adapter.list_assets(query))
|
|
237
|
+
return _fetch_listed(adapter, dataset, assets, root=root, force=force, staging=staging)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _fetch_listed(
|
|
241
|
+
adapter: Provider,
|
|
242
|
+
dataset: Dataset,
|
|
243
|
+
assets: list[Asset],
|
|
244
|
+
*,
|
|
245
|
+
root: Path | None = None,
|
|
246
|
+
force: bool = False,
|
|
247
|
+
staging: Path | None = None,
|
|
248
|
+
) -> list[FetchedAsset]:
|
|
249
|
+
"""Fetch assets already listed and put in order, as the loop does once it has listed them."""
|
|
226
250
|
_progress.batch([asset.size for asset in assets])
|
|
227
251
|
return [
|
|
228
252
|
_fetch_asset(dataset, a, adapter, root=root, force=force, staging=staging) for a in assets
|
|
@@ -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,15 +282,41 @@ def _product_name(asset_id: str) -> str | None:
|
|
|
266
282
|
return match["product"] if match else None
|
|
267
283
|
|
|
268
284
|
|
|
269
|
-
def
|
|
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
|
+
|
|
307
|
+
def _time(date: Any, time: Any, second: Any = None) -> str | None:
|
|
308
|
+
"""An ecCodes date and ``HHMM`` time as ISO 8601 UTC, with the message's seconds.
|
|
309
|
+
|
|
310
|
+
ecCodes spells times to the minute and keeps the reference time's seconds
|
|
311
|
+
in a key of their own, which MRMS sets: a file stamped ``20:00:41`` would
|
|
312
|
+
otherwise read as ``20:00:00``. A validity time is the reference time plus
|
|
313
|
+
a whole-minute step, so it carries the same seconds.
|
|
314
|
+
"""
|
|
270
315
|
if date is None or time is None:
|
|
271
316
|
return None
|
|
272
317
|
try:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
.replace(tzinfo=UTC)
|
|
276
|
-
.isoformat()
|
|
277
|
-
)
|
|
318
|
+
stamp = datetime.strptime(f"{int(date):08d}{int(time):04d}", "%Y%m%d%H%M")
|
|
319
|
+
return stamp.replace(second=int(second or 0), tzinfo=UTC).isoformat()
|
|
278
320
|
except ValueError:
|
|
279
321
|
return None
|
|
280
322
|
|
|
@@ -456,11 +498,12 @@ def open_grib2(
|
|
|
456
498
|
# The file's own spelling stays beside the rewritten one, as cfgrib keeps it.
|
|
457
499
|
attrs["GRIB_units"] = units
|
|
458
500
|
attrs["units"] = plain
|
|
501
|
+
second = _get(eccodes, h, "second", int)
|
|
459
502
|
for label, date_key, time_key in (
|
|
460
503
|
("reference_time", "dataDate", "dataTime"),
|
|
461
504
|
("valid_time", "validityDate", "validityTime"),
|
|
462
505
|
):
|
|
463
|
-
stamp = _time(attrs.get(date_key), attrs.get(time_key))
|
|
506
|
+
stamp = _time(attrs.get(date_key), attrs.get(time_key), second)
|
|
464
507
|
if stamp:
|
|
465
508
|
attrs[label] = stamp
|
|
466
509
|
message: dict[str, Any] = {
|
|
@@ -473,12 +516,9 @@ def open_grib2(
|
|
|
473
516
|
}
|
|
474
517
|
if selectors:
|
|
475
518
|
message["selector"] = selectors[index]
|
|
476
|
-
short =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
f"parameter_{attrs.get('discipline')}_{attrs.get('parameterCategory')}"
|
|
480
|
-
f"_{attrs.get('parameterNumber')}"
|
|
481
|
-
)
|
|
519
|
+
short = base_name(
|
|
520
|
+
message["shortName"], [attrs.get(key) for key in PARAMETER_KEYS], fetched.asset.id
|
|
521
|
+
)
|
|
482
522
|
selected.append(_Field(short=short, data=data, attrs=attrs, message=message))
|
|
483
523
|
if count > 1 and needs_select:
|
|
484
524
|
raise ValueError(
|
|
@@ -10,6 +10,8 @@ from pathlib import Path
|
|
|
10
10
|
from usdata.models import Asset
|
|
11
11
|
|
|
12
12
|
ENV_VAR = "USDATA_CACHE_DIR"
|
|
13
|
+
SIDECAR_SUFFIX = ".provenance.json"
|
|
14
|
+
"""What a provenance sidecar's name adds to its data file's; ``provenance`` names it from here."""
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def cache_dir() -> Path:
|
|
@@ -38,7 +40,9 @@ def cached_path(dataset_id: str, asset_id: str, root: Path | None = None) -> Pat
|
|
|
38
40
|
The path the cache uses for that asset, whether or not it exists.
|
|
39
41
|
|
|
40
42
|
Raises:
|
|
41
|
-
ValueError: Either id is unsafe,
|
|
43
|
+
ValueError: Either id is unsafe, names a sidecar or temporary file, or the
|
|
44
|
+
path would escape the cache root. Symlinks inside the root are
|
|
45
|
+
followed like any directory, so part of the cache can live elsewhere.
|
|
42
46
|
"""
|
|
43
47
|
provider, sep, name = dataset_id.partition(":")
|
|
44
48
|
if not sep or any(
|
|
@@ -48,11 +52,16 @@ def cached_path(dataset_id: str, asset_id: str, root: Path | None = None) -> Pat
|
|
|
48
52
|
safe_id = asset_id.replace("/", "_")
|
|
49
53
|
if not safe_id or safe_id in {".", ".."} or "\\" in safe_id or "\x00" in safe_id:
|
|
50
54
|
raise ValueError(f"unsafe asset id: {asset_id!r}")
|
|
55
|
+
# A file of this name would be taken for another asset's sidecar, or for a temporary file.
|
|
56
|
+
if safe_id.endswith(SIDECAR_SUFFIX) or (safe_id.startswith(".") and safe_id.endswith(".part")):
|
|
57
|
+
raise ValueError(f"asset id {asset_id!r} names a file the cache keeps for itself")
|
|
51
58
|
base = (root or cache_dir()).expanduser().resolve()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
home = base / provider / name
|
|
60
|
+
path = home / safe_id
|
|
61
|
+
# Checked on the path as written, so a directory the user symlinked elsewhere is still
|
|
62
|
+
# inside the cache; only an id that re-anchors the path, such as C:x on Windows, is not.
|
|
63
|
+
if path.parent != home:
|
|
64
|
+
raise ValueError(f"cache path escapes root: {path}")
|
|
56
65
|
return path
|
|
57
66
|
|
|
58
67
|
|
|
@@ -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
|
)
|