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.
Files changed (84) hide show
  1. {usdata-0.28.0 → usdata-0.30.0}/PKG-INFO +1 -1
  2. {usdata-0.28.0 → usdata-0.30.0}/pyproject.toml +1 -1
  3. {usdata-0.28.0 → usdata-0.30.0}/pyproject.toml.orig +1 -1
  4. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_fetch.py +47 -23
  5. usdata-0.30.0/src/usdata/_files.py +64 -0
  6. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_grib.py +64 -24
  7. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cache.py +14 -5
  8. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cache_ops.py +10 -5
  9. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/app.py +4 -1
  10. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/places.csv +3300 -3292
  11. usdata-0.30.0/src/usdata/data/places.sources.json +60 -0
  12. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/registry.yaml +8 -4
  13. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/inspect.py +40 -11
  14. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/manifest.py +80 -9
  15. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/models.py +36 -11
  16. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/erddap.py +3 -0
  17. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/http.py +48 -17
  18. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/provenance.py +1 -3
  19. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/base.py +25 -3
  20. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/epa/aqs.py +56 -20
  21. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/fema/declarations.py +10 -1
  22. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/coastwatch.py +5 -5
  23. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/grib_index.py +39 -11
  24. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/hrrr.py +2 -1
  25. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nws_vtec.py +4 -1
  26. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/earthquakes.py +18 -7
  27. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/pull.py +106 -25
  28. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/query.py +46 -12
  29. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/readers.py +34 -11
  30. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/registry.py +11 -1
  31. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/testing.py +4 -3
  32. usdata-0.28.0/src/usdata/_files.py +0 -29
  33. usdata-0.28.0/src/usdata/data/places.sources.json +0 -20
  34. {usdata-0.28.0 → usdata-0.30.0}/README.md +0 -0
  35. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/__init__.py +0 -0
  36. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/__main__.py +0 -0
  37. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_aqs.py +0 -0
  38. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_hurdat2.py +0 -0
  39. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_netcdf.py +0 -0
  40. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_progress.py +0 -0
  41. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/_radar.py +0 -0
  42. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cite.py +0 -0
  43. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/__init__.py +0 -0
  44. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/cache.py +0 -0
  45. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/cite.py +0 -0
  46. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/doctor.py +0 -0
  47. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/inspect.py +0 -0
  48. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/cli/progress.py +0 -0
  49. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/data/nexrad_sites.csv +0 -0
  50. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/doctor.py +0 -0
  51. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/mirror.py +0 -0
  52. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/__init__.py +0 -0
  53. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/listing.py +0 -0
  54. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/protocols/s3.py +0 -0
  55. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/__init__.py +0 -0
  56. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/credentials.py +0 -0
  57. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/epa/__init__.py +0 -0
  58. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/fema/__init__.py +0 -0
  59. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/http.py +0 -0
  60. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/__init__.py +0 -0
  61. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/coops.py +0 -0
  62. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gfs.py +0 -0
  63. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  64. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/glm.py +0 -0
  65. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/goes.py +0 -0
  66. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gsom.py +0 -0
  67. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  68. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  69. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  70. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/lcd.py +0 -0
  71. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/mrms.py +0 -0
  72. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nbm.py +0 -0
  73. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  74. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  75. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/normals.py +0 -0
  76. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/rap.py +0 -0
  77. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/sites.py +0 -0
  78. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/spc.py +0 -0
  79. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  80. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/params.py +0 -0
  81. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/__init__.py +0 -0
  82. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/providers/usgs/daily.py +0 -0
  83. {usdata-0.28.0 → usdata-0.30.0}/src/usdata/py.typed +0 -0
  84. {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.28.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.28.0"
3
+ version = "0.30.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.30.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:
@@ -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
- 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,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 _time(date: Any, time: Any) -> str | None:
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
- return (
274
- datetime.strptime(f"{int(date):08d}{int(time):04d}", "%Y%m%d%H%M")
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 = 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
- )
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, or the path would escape the cache root.
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
- path = base / provider / name / safe_id
53
- for candidate in (path, path.with_name(path.name + ".provenance.json")):
54
- if not candidate.resolve().is_relative_to(base):
55
- raise ValueError(f"cache path escapes root: {candidate}")
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: 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
  )