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.
Files changed (84) hide show
  1. {usdata-0.28.0 → usdata-0.29.0}/PKG-INFO +1 -1
  2. {usdata-0.28.0 → usdata-0.29.0}/pyproject.toml +1 -1
  3. {usdata-0.28.0 → usdata-0.29.0}/pyproject.toml.orig +1 -1
  4. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_fetch.py +34 -23
  5. usdata-0.29.0/src/usdata/_files.py +64 -0
  6. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_grib.py +52 -17
  7. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cache_ops.py +10 -5
  8. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/app.py +4 -1
  9. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/places.csv +2 -2
  10. usdata-0.29.0/src/usdata/data/places.sources.json +44 -0
  11. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/registry.yaml +8 -4
  12. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/inspect.py +40 -11
  13. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/manifest.py +2 -2
  14. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/models.py +16 -7
  15. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/erddap.py +3 -0
  16. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/http.py +48 -17
  17. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/base.py +3 -3
  18. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/epa/aqs.py +52 -19
  19. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/fema/declarations.py +4 -1
  20. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/grib_index.py +39 -11
  21. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/hrrr.py +2 -1
  22. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/earthquakes.py +18 -7
  23. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/pull.py +66 -14
  24. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/query.py +10 -7
  25. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/readers.py +28 -5
  26. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/registry.py +11 -1
  27. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/testing.py +4 -3
  28. usdata-0.28.0/src/usdata/_files.py +0 -29
  29. usdata-0.28.0/src/usdata/data/places.sources.json +0 -20
  30. {usdata-0.28.0 → usdata-0.29.0}/README.md +0 -0
  31. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/__init__.py +0 -0
  32. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/__main__.py +0 -0
  33. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_aqs.py +0 -0
  34. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_hurdat2.py +0 -0
  35. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_netcdf.py +0 -0
  36. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_progress.py +0 -0
  37. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/_radar.py +0 -0
  38. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cache.py +0 -0
  39. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cite.py +0 -0
  40. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/__init__.py +0 -0
  41. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/cache.py +0 -0
  42. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/cite.py +0 -0
  43. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/doctor.py +0 -0
  44. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/inspect.py +0 -0
  45. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/cli/progress.py +0 -0
  46. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/data/nexrad_sites.csv +0 -0
  47. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/doctor.py +0 -0
  48. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/mirror.py +0 -0
  49. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/__init__.py +0 -0
  50. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/listing.py +0 -0
  51. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/protocols/s3.py +0 -0
  52. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/provenance.py +0 -0
  53. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/__init__.py +0 -0
  54. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/credentials.py +0 -0
  55. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/epa/__init__.py +0 -0
  56. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/fema/__init__.py +0 -0
  57. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/http.py +0 -0
  58. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/__init__.py +0 -0
  59. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  60. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/coops.py +0 -0
  61. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gfs.py +0 -0
  62. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  63. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/glm.py +0 -0
  64. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/goes.py +0 -0
  65. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gsom.py +0 -0
  66. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  67. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  68. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  69. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/lcd.py +0 -0
  70. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/mrms.py +0 -0
  71. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nbm.py +0 -0
  72. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  73. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  74. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/normals.py +0 -0
  75. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  76. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/rap.py +0 -0
  77. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/sites.py +0 -0
  78. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/spc.py +0 -0
  79. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  80. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/params.py +0 -0
  81. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/__init__.py +0 -0
  82. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/providers/usgs/daily.py +0 -0
  83. {usdata-0.28.0 → usdata-0.29.0}/src/usdata/py.typed +0 -0
  84. {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.28.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.28.0"
3
+ version = "0.29.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.28.0"
3
+ version = "0.29.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"
@@ -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; a
153
- miss comes back with its ``path`` under ``staging``, for the caller to move
154
- home once it can pin it (ADR 0031).
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 not force and path.is_file():
161
- try:
162
- prov = provenance.read(path)
163
- except (ValueError, OSError):
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=path, provenance=prov, from_cache=True)
179
- if staging is not None:
180
- path = asset_path(asset, staging)
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
- SYMBOL_POWER = re.compile(r"(?<=[A-Za-z])\*\*")
48
- """A power written after a unit symbol, as ecCodes writes ``kg**-1``."""
49
- NUMERIC_POWER = re.compile(r"(?<![A-Za-z])\*\*")
50
- """A power written after anything else, such as the ``10**-3`` of a scale factor."""
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. A power
248
- of a number, such as ``10**-3``, has no such spelling, so a string holding
249
- one is returned as it is rather than half rewritten.
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 NUMERIC_POWER.search(units):
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 = message["shortName"]
477
- if short in (None, "", "unknown", "~"):
478
- short = _product_name(fetched.asset.id) or (
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: datetime = Field(description="Filesystem modification time, in UTC")
41
- retrieved_at: datetime | None = Field(
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 = now or datetime.now(UTC)
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,179.778470,71.387815
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,179.778470,57.249626
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: -60.0, north: 72.0 }
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: -60.0, north: 72.0 }
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
- (message.short_name or "", message.type_of_level, message.level)
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
- for message, name in zip(self.messages, names, strict=True):
143
- if message.selector == selector:
144
- return name
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: datetime
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
- return Grib2Summary(messages=_paired(readers.inventory(path), record))
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: datetime
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: datetime | None = None
132
- end: datetime | None = None
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: datetime
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: datetime
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:]]