usdata 0.17.0__tar.gz → 0.18.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 (72) hide show
  1. {usdata-0.17.0 → usdata-0.18.0}/PKG-INFO +1 -1
  2. {usdata-0.17.0 → usdata-0.18.0}/pyproject.toml +1 -1
  3. {usdata-0.17.0 → usdata-0.18.0}/pyproject.toml.orig +1 -1
  4. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/__init__.py +2 -1
  5. usdata-0.18.0/src/usdata/__main__.py +8 -0
  6. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_fetch.py +26 -5
  7. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_grib.py +89 -20
  8. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cache.py +5 -0
  9. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cite.py +67 -5
  10. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/app.py +66 -6
  11. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/inspect.py +19 -2
  12. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/registry.yaml +12 -10
  13. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/inspect.py +84 -8
  14. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/manifest.py +9 -7
  15. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/models.py +123 -0
  16. usdata-0.18.0/src/usdata/protocols/http.py +261 -0
  17. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/s3.py +30 -2
  18. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/provenance.py +25 -3
  19. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/base.py +22 -1
  20. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gfs.py +21 -9
  21. usdata-0.18.0/src/usdata/providers/noaa/grib_index.py +210 -0
  22. usdata-0.18.0/src/usdata/providers/noaa/hrrr.py +438 -0
  23. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/pull.py +125 -11
  24. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/readers.py +72 -5
  25. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/registry.py +3 -1
  26. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/testing.py +15 -0
  27. usdata-0.17.0/src/usdata/protocols/http.py +0 -113
  28. usdata-0.17.0/src/usdata/providers/noaa/hrrr.py +0 -224
  29. {usdata-0.17.0 → usdata-0.18.0}/README.md +0 -0
  30. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_files.py +0 -0
  31. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_hurdat2.py +0 -0
  32. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_netcdf.py +0 -0
  33. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_progress.py +0 -0
  34. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_radar.py +0 -0
  35. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cache_ops.py +0 -0
  36. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/__init__.py +0 -0
  37. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/cache.py +0 -0
  38. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/cite.py +0 -0
  39. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/doctor.py +0 -0
  40. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/progress.py +0 -0
  41. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/nexrad_sites.csv +0 -0
  42. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/places.csv +0 -0
  43. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/places.sources.json +0 -0
  44. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/doctor.py +0 -0
  45. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/__init__.py +0 -0
  46. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/erddap.py +0 -0
  47. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/listing.py +0 -0
  48. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/__init__.py +0 -0
  49. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/http.py +0 -0
  50. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/__init__.py +0 -0
  51. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  52. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/coops.py +0 -0
  53. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  54. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/glm.py +0 -0
  55. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/goes.py +0 -0
  56. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsom.py +0 -0
  57. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  58. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  59. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/lcd.py +0 -0
  60. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/mrms.py +0 -0
  61. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  62. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  63. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/normals.py +0 -0
  64. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/sites.py +0 -0
  65. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/spc.py +0 -0
  66. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  67. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/params.py +0 -0
  68. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/usgs/__init__.py +0 -0
  69. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/usgs/daily.py +0 -0
  70. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/py.typed +0 -0
  71. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/query.py +0 -0
  72. {usdata-0.17.0 → usdata-0.18.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.17.0
3
+ Version: 0.18.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,nasa,open-data,scientific-data,provenance
6
6
  Author: Jake Van Slyke
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.17.0"
3
+ version = "0.18.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.17.0"
3
+ version = "0.18.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"
@@ -11,7 +11,7 @@ except PackageNotFoundError: # running from a source tree without an install
11
11
  __version__ = "0.0.0"
12
12
 
13
13
  from usdata._fetch import ChecksumMismatch, FetchedAsset, fetch, fetch_asset
14
- from usdata.cite import cite_dataset, cite_lockfile
14
+ from usdata.cite import Citation, cite_dataset, cite_lockfile
15
15
  from usdata.inspect import inspect_asset, inspect_path
16
16
  from usdata.models import Asset, BBox, Dataset, Provenance, Query, TemporalSelection, TimeRange
17
17
  from usdata.pull import pull, verify
@@ -23,6 +23,7 @@ __all__ = [
23
23
  "Asset",
24
24
  "BBox",
25
25
  "ChecksumMismatch",
26
+ "Citation",
26
27
  "Dataset",
27
28
  "DatasetNotFound",
28
29
  "FetchedAsset",
@@ -0,0 +1,8 @@
1
+ """Module entry point: ``python -m usdata`` runs the same CLI the script does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from usdata.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from collections.abc import Mapping
6
7
  from pathlib import Path
7
8
  from typing import TYPE_CHECKING, Any
@@ -105,7 +106,14 @@ def _fetch_asset(
105
106
  *,
106
107
  root: Path | None = None,
107
108
  force: bool = False,
109
+ pinned: Provenance | None = None,
108
110
  ) -> FetchedAsset:
111
+ """Fetch one asset through the cache, passing any pinned record to the adapter.
112
+
113
+ ``pinned`` is the provenance a lockfile holds for this asset. The adapter sees
114
+ it through ``prepare_fetch``, so an asset that pins byte ranges is reproduced
115
+ from the record rather than by resolving its query again.
116
+ """
109
117
  if asset.dataset_id != dataset.id:
110
118
  raise ValueError(f"asset dataset {asset.dataset_id!r} does not match {dataset.id!r}")
111
119
  _progress.emit(_progress.AssetProgress(asset.id, "start", asset.size))
@@ -128,8 +136,9 @@ def _fetch_asset(
128
136
  _progress.emit(_progress.AssetProgress(asset.id, "cached", prov.size))
129
137
  return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=True)
130
138
  with staged_path(path) as tmp:
139
+ partial = adapter.prepare_fetch(asset, pinned)
131
140
  adapter.fetch(asset, tmp)
132
- prov = provenance.record(dataset, asset, tmp)
141
+ prov = provenance.record(dataset, asset, tmp, partial)
133
142
  if asset.checksum and prov.checksum != asset.checksum:
134
143
  raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {prov.checksum}")
135
144
  # A crash between replacements leaves a detectable mismatch, never a trusted partial file.
@@ -139,11 +148,17 @@ def _fetch_asset(
139
148
 
140
149
 
141
150
  def fetch_asset(
142
- dataset: Dataset, asset: Asset, *, root: Path | None = None, force: bool = False
151
+ dataset: Dataset,
152
+ asset: Asset,
153
+ *,
154
+ root: str | os.PathLike[str] | None = None,
155
+ force: bool = False,
143
156
  ) -> FetchedAsset:
144
157
  """Fetch one asset, reusing the cache only when its provenance still describes it."""
145
158
  with load_adapter(dataset) as adapter:
146
- return _fetch_asset(dataset, asset, adapter, root=root, force=force)
159
+ return _fetch_asset(
160
+ dataset, asset, adapter, root=None if root is None else Path(root), force=force
161
+ )
147
162
 
148
163
 
149
164
  def _fetch_with(
@@ -161,8 +176,14 @@ def _fetch_with(
161
176
 
162
177
 
163
178
  def fetch(
164
- dataset: Dataset, query: Query, *, root: Path | None = None, force: bool = False
179
+ dataset: Dataset,
180
+ query: Query,
181
+ *,
182
+ root: str | os.PathLike[str] | None = None,
183
+ force: bool = False,
165
184
  ) -> list[FetchedAsset]:
166
185
  """Resolve and fetch a query, sharing one adapter and closing its owned resources."""
167
186
  with load_adapter(dataset) as adapter:
168
- return _fetch_with(adapter, dataset, query, root=root, force=force)
187
+ return _fetch_with(
188
+ adapter, dataset, query, root=None if root is None else Path(root), force=force
189
+ )
@@ -3,7 +3,9 @@
3
3
  Messages are decoded one at a time with the ecCodes Python bindings, never
4
4
  cfgrib. The reader builds the xarray Dataset itself: one data variable per
5
5
  selected message on one shared grid, coordinates computed from the grid
6
- definition, and provenance under ``attrs["usdata"]``. Values arrive from
6
+ definition, and provenance under ``attrs["usdata"]``. Variable names follow
7
+ from the set of selected messages alone, never from which short names happen to
8
+ repeat, so the same select always yields the same names. Values arrive from
7
9
  ecCodes as float64, are masked to NaN where the message's bitmap marks them
8
10
  missing, and are stored as float32; the float64 array is released before the
9
11
  Dataset is returned. Rows are ordered north to south and columns west to east
@@ -15,13 +17,13 @@ from __future__ import annotations
15
17
  import gzip
16
18
  import re
17
19
  import warnings
18
- from collections.abc import Iterator, Mapping
20
+ from collections.abc import Iterator, Mapping, Sequence
19
21
  from contextlib import contextmanager
20
22
  from datetime import UTC, datetime
21
23
  from importlib import import_module
22
24
  from inspect import currentframe
23
25
  from pathlib import Path
24
- from typing import TYPE_CHECKING, Any
26
+ from typing import TYPE_CHECKING, Any, NamedTuple
25
27
 
26
28
  from usdata.inspect import GribMessage
27
29
  from usdata.readers import MissingReaderDependency, fill_registry_attrs
@@ -205,7 +207,7 @@ def inventory(path: Path) -> list[GribMessage]:
205
207
  keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
206
208
  messages.append(
207
209
  GribMessage(
208
- index=index,
210
+ file_index=index,
209
211
  short_name=keys["shortName"],
210
212
  name=keys["name"],
211
213
  type_of_level=keys["typeOfLevel"],
@@ -298,11 +300,64 @@ class _Grid:
298
300
  return numpy.ascontiguousarray(grid)
299
301
 
300
302
 
303
+ class _Field(NamedTuple):
304
+ """One decoded message: its short name, values, variable attributes, and identity."""
305
+
306
+ short: str
307
+ data: Any
308
+ attrs: dict[str, Any]
309
+ message: dict[str, Any]
310
+
311
+
312
+ def variable_names(fields: Sequence[tuple[str, Any, Any]]) -> list[str]:
313
+ """The names one set of selected messages takes, under the one GRIB2 naming rule.
314
+
315
+ Every name is the bare short name while the messages share one type of level
316
+ and level, and every name is ``short_typeOfLevel_level`` as soon as they span
317
+ more than one, so the names follow from the selection rather than from which
318
+ short names happened to repeat. A name that would still repeat gains its
319
+ position.
320
+
321
+ Args:
322
+ fields: One ``(short name, type of level, level)`` triple per message, in
323
+ the order the messages appear.
324
+
325
+ Returns:
326
+ One variable name per field, in the same order.
327
+ """
328
+ spans = len({(level_type, level) for _, level_type, level in fields}) > 1
329
+ names: list[str] = []
330
+ for short, level_type, level in fields:
331
+ label = short
332
+ if spans:
333
+ kind = "level" if level_type is None else level_type
334
+ label = f"{short}_{kind}_{'' if level is None else level}"
335
+ if label in names:
336
+ label = f"{label}_{len(names)}"
337
+ names.append(label)
338
+ return names
339
+
340
+
301
341
  def open_grib2(
302
342
  fetched: FetchedAsset, select: Mapping[str, Any] | None = None, *, strict: bool = False
303
343
  ) -> Any:
304
344
  """Decode selected GRIB2 messages into one loaded xarray Dataset.
305
345
 
346
+ A file of several messages needs ``select``, unless its provenance says the
347
+ fetch already selected them: a partial fetch concatenated the messages that
348
+ were asked for, so all of them are wanted and ``select`` stays optional.
349
+
350
+ Variables take their ``shortName`` when every selected message shares one
351
+ type of level and level, and ``shortName_typeOfLevel_level`` for all of
352
+ them as soon as the selection spans more than one, so the names follow from
353
+ the select rather than from which short names happened to repeat.
354
+ ``attrs["usdata"]["messages"]`` maps each variable name to the message it
355
+ came from: ``file_index`` numbers it in this file from zero, and
356
+ ``object_index`` numbers it in the source object as that object's index
357
+ sidecar does, one-based, for a partial fetch and ``None`` for a whole file.
358
+ A partial fetch's entries also carry ``selector``, the index selector the
359
+ message was fetched for.
360
+
306
361
  A select value that matches none of the selected messages warns, or raises
307
362
  ``ValueError`` when ``strict``; a select that matches nothing always raises.
308
363
  """
@@ -312,13 +367,15 @@ def open_grib2(
312
367
  options = {key: _options(key, wanted) for key, wanted in (select or {}).items()}
313
368
  matched: dict[str, set[int]] = {key: set() for key in options}
314
369
  present: dict[str, list[str]] = {key: [] for key in options}
315
- fields: dict[str, tuple[Any, dict[str, Any]]] = {}
370
+ selected: list[_Field] = []
316
371
  grid: _Grid | None = None
317
372
  count = 0
318
- names: list[str] = []
373
+ needs_select = select is None and not fetched.provenance.is_partial
374
+ object_messages = fetched.provenance.object_messages
375
+ selectors = fetched.provenance.selectors if object_messages else []
319
376
  for h in _messages(eccodes, fetched.path):
320
377
  count += 1
321
- if count > 1 and select is None:
378
+ if count > 1 and needs_select:
322
379
  continue
323
380
  hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
324
381
  missed = [key for key, indexes in hits.items() if not indexes]
@@ -357,21 +414,30 @@ def open_grib2(
357
414
  stamp = _time(attrs.get(date_key), attrs.get(time_key))
358
415
  if stamp:
359
416
  attrs[label] = stamp
360
- short = _get(eccodes, h, "shortName", str)
417
+ message: dict[str, Any] = {
418
+ "file_index": count - 1,
419
+ "object_index": object_messages[count - 1] if object_messages else None,
420
+ "shortName": _get(eccodes, h, "shortName", str),
421
+ "typeOfLevel": attrs.get("typeOfLevel"),
422
+ "level": attrs.get("level"),
423
+ "step": attrs.get("step"),
424
+ }
425
+ if selectors:
426
+ message["selector"] = selectors[count - 1]
427
+ short = message["shortName"]
361
428
  if short in (None, "", "unknown", "~"):
362
429
  short = _product_name(fetched.asset.id) or (
363
430
  f"parameter_{attrs.get('discipline')}_{attrs.get('parameterCategory')}"
364
431
  f"_{attrs.get('parameterNumber')}"
365
432
  )
366
- names.append(short)
367
- fields[f"{short}#{len(names)}"] = (data, attrs)
368
- if count > 1 and select is None:
433
+ selected.append(_Field(short=short, data=data, attrs=attrs, message=message))
434
+ if count > 1 and needs_select:
369
435
  raise ValueError(
370
436
  f"{count} messages; pass select={{...}} with ecCodes keys to choose, "
371
437
  f"for example select={{'shortName': ..., 'typeOfLevel': ...}}. "
372
438
  f"Available (shortName, typeOfLevel, level): {_available(fetched.path)}"
373
439
  )
374
- if grid is None or not fields:
440
+ if grid is None or not selected:
375
441
  if not count:
376
442
  raise ValueError("no GRIB2 messages found")
377
443
  raise ValueError(
@@ -383,14 +449,16 @@ def open_grib2(
383
449
  raise ValueError(report)
384
450
  warnings.warn(report, UserWarning, stacklevel=_caller_stacklevel())
385
451
  variables: dict[str, Any] = {}
386
- duplicates = {name for name in names if names.count(name) > 1}
387
- for (data, attrs), name in zip(fields.values(), names, strict=True):
388
- label = name
389
- if name in duplicates:
390
- label = f"{name}_{attrs.get('typeOfLevel', 'level')}_{attrs.get('level', '')}"
391
- if label in variables:
392
- label = f"{label}_{len(variables)}"
393
- variables[label] = (grid.dims, data, attrs)
452
+ messages: dict[str, dict[str, Any]] = {}
453
+ names = variable_names(
454
+ [
455
+ (field.short, field.attrs.get("typeOfLevel"), field.attrs.get("level"))
456
+ for field in selected
457
+ ]
458
+ )
459
+ for label, field in zip(names, selected, strict=True):
460
+ variables[label] = (grid.dims, field.data, field.attrs)
461
+ messages[label] = field.message
394
462
  dataset = xarray.Dataset(variables, coords=grid.coords)
395
463
  dataset.latitude.attrs["units"] = "degrees_north"
396
464
  dataset.longitude.attrs["units"] = "degrees_east"
@@ -398,6 +466,7 @@ def open_grib2(
398
466
  dataset.attrs["usdata"] = {
399
467
  "asset_id": fetched.asset.id,
400
468
  "provenance": fetched.provenance.model_dump(mode="json"),
469
+ "messages": messages,
401
470
  }
402
471
  fill_registry_attrs(fetched, dataset)
403
472
  return dataset
@@ -56,6 +56,11 @@ def cached_path(dataset_id: str, asset_id: str, root: Path | None = None) -> Pat
56
56
  return path
57
57
 
58
58
 
59
+ def sha256_bytes(data: bytes) -> str:
60
+ """Hex sha256 of a byte string, prefixed 'sha256:' as ``sha256_file`` prefixes a file's."""
61
+ return f"sha256:{hashlib.sha256(data).hexdigest()}"
62
+
63
+
59
64
  def sha256_file(path: Path) -> str:
60
65
  """Hex sha256 of a file, prefixed 'sha256:' to match Asset.checksum."""
61
66
  with path.open("rb") as f:
@@ -7,6 +7,7 @@ Rendering is plain text for a methods section or BibTeX for a bibliography.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import re
11
12
  from collections.abc import Iterable
12
13
  from pathlib import Path
@@ -21,6 +22,18 @@ from usdata.registry import Registry, default_registry
21
22
  ACCESSED_VIA = "accessed via usdata"
22
23
  """Tail of the fallback citation used when a registry entry states none."""
23
24
 
25
+ DATE_PLACEHOLDER = "[date]"
26
+ """Literal an agency's requested citation leaves where the retrieval date belongs."""
27
+
28
+ PLACEHOLDER_NOTE = (
29
+ f"{DATE_PLACEHOLDER} is filled from a lockfile; cite a pulled manifest to date this"
30
+ )
31
+ """Note rendered beside a citation whose placeholder no lockfile has filled."""
32
+
33
+ UNRECORDED_DATE = "an unrecorded date"
34
+ """Stand-in for a retrieval range that carries no bounds at all."""
35
+
36
+ _ON_PLACEHOLDER = re.compile(rf"\bon {re.escape(DATE_PLACEHOLDER)}")
24
37
  _NOT_KEY = re.compile(r"[^A-Za-z0-9]+")
25
38
  _BIBTEX_SPECIAL = re.compile(r"([&%$#_])")
26
39
 
@@ -50,6 +63,23 @@ class Citation(BaseModel):
50
63
  default_factory=list, description="Keys of the manifest sources that pinned the assets"
51
64
  )
52
65
 
66
+ def as_text(self) -> str:
67
+ """Render this citation the way ``usdata cite`` prints it.
68
+
69
+ Returns:
70
+ The dataset id, the citation text, and each field that is set, one
71
+ labelled line apiece.
72
+ """
73
+ return render_text([self])
74
+
75
+ def as_bibtex(self) -> str:
76
+ """Render this citation the way ``usdata cite --format bibtex`` prints it.
77
+
78
+ Returns:
79
+ One ``@misc`` entry keyed on the dataset id.
80
+ """
81
+ return render_bibtex([self])
82
+
53
83
 
54
84
  def cite_dataset(dataset: Dataset, *, registry: Registry | None = None) -> Citation:
55
85
  """Build the citation for one registry entry.
@@ -74,11 +104,14 @@ def cite_dataset(dataset: Dataset, *, registry: Registry | None = None) -> Citat
74
104
  )
75
105
 
76
106
 
77
- def cite_lockfile(manifest_path: Path, registry: Registry | None = None) -> list[Citation]:
107
+ def cite_lockfile(
108
+ manifest_path: str | os.PathLike[str], registry: Registry | None = None
109
+ ) -> list[Citation]:
78
110
  """Cite every dataset a manifest's lockfile pins, one citation per dataset.
79
111
 
80
112
  Args:
81
- manifest_path: The manifest whose lockfile records what was fetched.
113
+ manifest_path: The manifest whose lockfile records what was fetched,
114
+ written as a string or as any ``os.PathLike``.
82
115
  registry: Registry the pinned dataset ids are looked up in. The bundled
83
116
  registry by default.
84
117
 
@@ -92,6 +125,7 @@ def cite_lockfile(manifest_path: Path, registry: Registry | None = None) -> list
92
125
  DatasetNotFound: The lockfile pins a dataset the registry does not know.
93
126
  """
94
127
  reg = registry or default_registry()
128
+ manifest_path = Path(manifest_path)
95
129
  lock = Lockfile.load(lockfile_path(manifest_path))
96
130
  _check_manifest(manifest_path, lock)
97
131
  grouped: dict[str, list[LockedAsset]] = {}
@@ -126,11 +160,13 @@ def _cite_pinned(
126
160
  ) -> Citation:
127
161
  """One dataset's citation, extended with what its lockfile entries record."""
128
162
  times = [entry.provenance.retrieved_at for entry in entries]
163
+ retrieved = TimeRange(start=min(times), end=max(times))
129
164
  base = cite_dataset(registry.get(dataset_id), registry=registry)
130
165
  return base.model_copy(
131
166
  update={
167
+ "text": _fill_date(base.text, retrieved),
132
168
  "license": base.license or entries[0].provenance.license,
133
- "retrieved": TimeRange(start=min(times), end=max(times)),
169
+ "retrieved": retrieved,
134
170
  "asset_count": len(entries),
135
171
  "total_bytes": sum(entry.provenance.size for entry in entries),
136
172
  "usdata_version": usdata_version,
@@ -139,10 +175,31 @@ def _cite_pinned(
139
175
  )
140
176
 
141
177
 
178
+ def _dates(retrieved: TimeRange) -> list[str]:
179
+ """The distinct ISO dates a retrieval range touches, earliest first."""
180
+ bounds = [moment.date().isoformat() for moment in (retrieved.start, retrieved.end) if moment]
181
+ return list(dict.fromkeys(bounds))
182
+
183
+
184
+ def _fill_date(text: str, retrieved: TimeRange) -> str:
185
+ """The citation text with any literal ``[date]`` replaced by when it was retrieved.
186
+
187
+ A range within one day reads as that date, and a wider one as 'between A and
188
+ B', so no rendering of a lockfile citation keeps the placeholder.
189
+ """
190
+ if DATE_PLACEHOLDER not in text:
191
+ return text
192
+ dates = _dates(retrieved)
193
+ if len(dates) < 2:
194
+ return text.replace(DATE_PLACEHOLDER, dates[0] if dates else UNRECORDED_DATE)
195
+ span = f"between {dates[0]} and {dates[-1]}"
196
+ # 'accessed on [date]' has to read 'accessed between A and B', not 'on between'.
197
+ return _ON_PLACEHOLDER.sub(span, text).replace(DATE_PLACEHOLDER, span)
198
+
199
+
142
200
  def _retrieval(citation: Citation, retrieved: TimeRange) -> str:
143
201
  """The retrieval dates, checksummed asset count, size, and pinning version."""
144
- bounds = [moment.date().isoformat() for moment in (retrieved.start, retrieved.end) if moment]
145
- dates = " through ".join(dict.fromkeys(bounds)) or "an unrecorded date"
202
+ dates = " through ".join(_dates(retrieved)) or UNRECORDED_DATE
146
203
  assets = "asset" if citation.asset_count == 1 else "assets"
147
204
  version = f" by usdata {citation.usdata_version}" if citation.usdata_version else ""
148
205
  return (
@@ -164,6 +221,8 @@ def _text_lines(citation: Citation) -> list[str]:
164
221
  lines.append(f" retrieved: {_retrieval(citation, retrieved)}")
165
222
  if citation.sources:
166
223
  lines.append(f" sources: {', '.join(citation.sources)}")
224
+ if DATE_PLACEHOLDER in citation.text:
225
+ lines.append(f" note: {PLACEHOLDER_NOTE}")
167
226
  return lines
168
227
 
169
228
 
@@ -189,6 +248,9 @@ def _escape(value: str) -> str:
189
248
 
190
249
  __all__ = [
191
250
  "ACCESSED_VIA",
251
+ "DATE_PLACEHOLDER",
252
+ "PLACEHOLDER_NOTE",
253
+ "UNRECORDED_DATE",
192
254
  "Citation",
193
255
  "cite_dataset",
194
256
  "cite_lockfile",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from collections.abc import Iterable
6
7
  from pathlib import Path
7
8
  from typing import Annotated
8
9
 
@@ -22,7 +23,8 @@ from usdata.manifest import lockfile_path
22
23
  from usdata.models import READER_EXTRAS_TEXT, Dataset, Status, describe_duration
23
24
  from usdata.providers import load_adapter
24
25
  from usdata.providers.base import NotImplementedProvider
25
- from usdata.pull import EmptySource, ManifestChanged, UnknownDatasets, UpstreamChanged
26
+ from usdata.pull import EmptySource, ManifestChanged, Plan, UnknownDatasets, UpstreamChanged
27
+ from usdata.pull import plan as plan_manifest
26
28
  from usdata.pull import pull as pull_manifest
27
29
  from usdata.pull import verify as verify_manifest
28
30
  from usdata.query import UnknownPlace
@@ -57,6 +59,25 @@ _QUERY_FLAGS = {
57
59
  }
58
60
 
59
61
 
62
+ def _size_phrase(known_bytes: int, unknown: int, *, sources: Iterable[str] = ()) -> str:
63
+ """How many bytes a dry run would move, said honestly when some size is missing.
64
+
65
+ A bare total would read as a measurement; when an adapter reports no size for
66
+ an asset, the known bytes are a floor and the sources that withheld a size are
67
+ named instead.
68
+ """
69
+ if not unknown:
70
+ return f"{known_bytes} bytes"
71
+ phrase = f"at least {known_bytes} bytes; size unknown for {unknown} asset(s)"
72
+ named = ", ".join(sources)
73
+ return f"{phrase} from {named}" if named else phrase
74
+
75
+
76
+ def _asset_line(asset_id: str, size: int | None, href: str) -> str:
77
+ """One dry-run row: id, size or '?' when the adapter reported none, and the href."""
78
+ return f"{asset_id}\t{size if size is not None else '?'}\t{href}"
79
+
80
+
60
81
  _STATUS_HELP = f"Support status to include: {', '.join(STATUS_FILTERS)}."
61
82
 
62
83
  _ProviderOption = Annotated[str | None, typer.Option(help="Restrict to one provider, e.g. noaa.")]
@@ -368,12 +389,11 @@ def fetch(
368
389
  typer.echo(json.dumps([a.model_dump(mode="json") for a in assets], indent=2))
369
390
  else:
370
391
  for a in assets:
371
- typer.echo(f"{a.id}\t{a.size if a.size is not None else '?'}\t{a.href}")
392
+ typer.echo(_asset_line(a.id, a.size, a.href))
372
393
  known = [a.size for a in assets if a.size is not None]
373
- summary = f"{len(assets)} asset(s) matched, {sum(known)} bytes"
374
- if len(known) != len(assets):
375
- summary += f"; size unknown for {len(assets) - len(known)}"
376
- typer.echo(summary, err=True)
394
+ unknown = len(assets) - len(known)
395
+ sizes = _size_phrase(sum(known), unknown, sources=[ds.id] if unknown else [])
396
+ typer.echo(f"{len(assets)} asset(s) matched, {sizes}", err=True)
377
397
  with progress(disabled=no_progress):
378
398
  batch([asset.size for asset in assets])
379
399
  return
@@ -399,6 +419,26 @@ def fetch(
399
419
  typer.echo(f"{f.path}\t{tag}\t{f.provenance.size} bytes")
400
420
 
401
421
 
422
+ def _print_plan(plan: Plan, *, as_json: bool, quiet: bool) -> None:
423
+ """Report what a pull would fetch: assets, a subtotal per source, a total on stderr."""
424
+ if as_json:
425
+ typer.echo(plan.model_dump_json(indent=2))
426
+ else:
427
+ for source in plan.sources:
428
+ if not quiet:
429
+ for asset in source.assets:
430
+ typer.echo(_asset_line(asset.id, asset.size, asset.href))
431
+ sizes = _size_phrase(source.known_bytes, source.unknown_sizes)
432
+ typer.echo(
433
+ f"{source.source} ({source.dataset_id}): {len(source.assets)} asset(s), {sizes}"
434
+ )
435
+ total = _size_phrase(plan.known_bytes, plan.unknown_sizes, sources=plan.unsized_sources)
436
+ typer.echo(
437
+ f"{len(plan.sources)} source(s), {plan.asset_count} asset(s), {total}; nothing downloaded",
438
+ err=True,
439
+ )
440
+
441
+
402
442
  @app.command()
403
443
  def pull(
404
444
  manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
@@ -420,9 +460,29 @@ def pull(
420
460
  bool,
421
461
  typer.Option("--quiet", "-q", help="Print only the summary line, not one line per asset."),
422
462
  ] = False,
463
+ dry_run: Annotated[
464
+ bool,
465
+ typer.Option(help="List what every source would fetch, and its size, without downloading."),
466
+ ] = False,
467
+ as_json: Annotated[
468
+ bool, typer.Option("--json", help="With --dry-run, emit the plan as JSON on stdout.")
469
+ ] = False,
423
470
  ) -> None:
424
471
  """Fetch every source in a manifest and write (or restore from) its lockfile."""
472
+ if as_json and not dry_run:
473
+ typer.secho("--json applies to --dry-run only", err=True, fg="red")
474
+ raise typer.Exit(code=2)
475
+ if dry_run and update:
476
+ typer.secho(
477
+ "--dry-run prices a fresh resolve of every source; it cannot update pins",
478
+ err=True,
479
+ fg="red",
480
+ )
481
+ raise typer.Exit(code=2)
425
482
  try:
483
+ if dry_run:
484
+ _print_plan(plan_manifest(manifest), as_json=as_json, quiet=quiet)
485
+ return
426
486
  with progress(disabled=no_progress):
427
487
  result = pull_manifest(manifest, root=cache_dir, force=force, update=update or [])
428
488
  except EmptySource as e:
@@ -111,12 +111,29 @@ def _echo_detail(summary: Summary, width: int) -> None:
111
111
  ],
112
112
  )
113
113
  if (fields := summary.grib2) is not None:
114
+ # A partial fetch knows both numberings and the selector each message
115
+ # was asked for; a whole file knows neither, so those columns are dropped.
116
+ in_object = any(message.object_index is not None for message in fields.messages)
117
+ asked = any(message.selector is not None for message in fields.messages)
118
+ numbering = ["file #", "object #"] if in_object else ["#"]
114
119
  _echo_table(
115
120
  "messages",
116
- ["#", "shortName", "name", "typeOfLevel", "level", "step", "units", "grid"],
121
+ [
122
+ *numbering,
123
+ *(["selector"] if asked else []),
124
+ "shortName",
125
+ "name",
126
+ "typeOfLevel",
127
+ "level",
128
+ "step",
129
+ "units",
130
+ "grid",
131
+ ],
117
132
  [
118
133
  [
119
- str(message.index),
134
+ str(message.file_index),
135
+ *([str(message.object_index or "")] if in_object else []),
136
+ *([message.selector or ""] if asked else []),
120
137
  message.short_name or "",
121
138
  message.name or "",
122
139
  message.type_of_level or "",