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.
- {usdata-0.17.0 → usdata-0.18.0}/PKG-INFO +1 -1
- {usdata-0.17.0 → usdata-0.18.0}/pyproject.toml +1 -1
- {usdata-0.17.0 → usdata-0.18.0}/pyproject.toml.orig +1 -1
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/__init__.py +2 -1
- usdata-0.18.0/src/usdata/__main__.py +8 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_fetch.py +26 -5
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_grib.py +89 -20
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cache.py +5 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cite.py +67 -5
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/app.py +66 -6
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/inspect.py +19 -2
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/registry.yaml +12 -10
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/inspect.py +84 -8
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/manifest.py +9 -7
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/models.py +123 -0
- usdata-0.18.0/src/usdata/protocols/http.py +261 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/s3.py +30 -2
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/provenance.py +25 -3
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/base.py +22 -1
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gfs.py +21 -9
- usdata-0.18.0/src/usdata/providers/noaa/grib_index.py +210 -0
- usdata-0.18.0/src/usdata/providers/noaa/hrrr.py +438 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/pull.py +125 -11
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/readers.py +72 -5
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/registry.py +3 -1
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/testing.py +15 -0
- usdata-0.17.0/src/usdata/protocols/http.py +0 -113
- usdata-0.17.0/src/usdata/providers/noaa/hrrr.py +0 -224
- {usdata-0.17.0 → usdata-0.18.0}/README.md +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_files.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_hurdat2.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_netcdf.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_progress.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/_radar.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cache_ops.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/__init__.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/cache.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/cite.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/doctor.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/cli/progress.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/nexrad_sites.csv +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/places.csv +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/data/places.sources.json +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/doctor.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/__init__.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/erddap.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/protocols/listing.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/__init__.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/http.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/__init__.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/coops.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/glm.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/goes.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsom.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsoy.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/lcd.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/mrms.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/normals.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/sites.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/spc.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/noaa/storm_events.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/params.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/usgs/__init__.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/providers/usgs/daily.py +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/py.typed +0 -0
- {usdata-0.17.0 → usdata-0.18.0}/src/usdata/query.py +0 -0
- {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.
|
|
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
|
|
@@ -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",
|
|
@@ -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,
|
|
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(
|
|
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,
|
|
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(
|
|
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"]``.
|
|
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
|
-
|
|
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
|
-
|
|
370
|
+
selected: list[_Field] = []
|
|
316
371
|
grid: _Grid | None = None
|
|
317
372
|
count = 0
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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(
|
|
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":
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
[
|
|
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.
|
|
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 "",
|