usdata 0.16.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 (77) hide show
  1. {usdata-0.16.0 → usdata-0.18.0}/PKG-INFO +3 -2
  2. {usdata-0.16.0 → usdata-0.18.0}/README.md +2 -1
  3. {usdata-0.16.0 → usdata-0.18.0}/pyproject.toml +1 -1
  4. {usdata-0.16.0 → usdata-0.18.0}/pyproject.toml.orig +1 -1
  5. usdata-0.18.0/src/usdata/__init__.py +99 -0
  6. usdata-0.18.0/src/usdata/__main__.py +8 -0
  7. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_fetch.py +53 -10
  8. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_grib.py +222 -58
  9. usdata-0.18.0/src/usdata/_netcdf.py +69 -0
  10. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cache.py +26 -4
  11. usdata-0.18.0/src/usdata/cache_ops.py +249 -0
  12. usdata-0.18.0/src/usdata/cite.py +259 -0
  13. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/app.py +262 -22
  14. usdata-0.18.0/src/usdata/cli/cache.py +132 -0
  15. usdata-0.18.0/src/usdata/cli/cite.py +76 -0
  16. usdata-0.18.0/src/usdata/cli/doctor.py +33 -0
  17. usdata-0.18.0/src/usdata/cli/inspect.py +164 -0
  18. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/registry.yaml +639 -257
  19. usdata-0.18.0/src/usdata/doctor.py +229 -0
  20. usdata-0.18.0/src/usdata/inspect.py +322 -0
  21. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/manifest.py +35 -7
  22. usdata-0.18.0/src/usdata/models.py +561 -0
  23. usdata-0.18.0/src/usdata/protocols/http.py +261 -0
  24. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/s3.py +30 -2
  25. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/provenance.py +25 -3
  26. usdata-0.18.0/src/usdata/providers/__init__.py +37 -0
  27. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/base.py +22 -1
  28. usdata-0.18.0/src/usdata/providers/http.py +44 -0
  29. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/coastwatch.py +2 -2
  30. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/coops.py +2 -2
  31. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gfs.py +21 -9
  32. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/ghcnd.py +2 -2
  33. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/glm.py +2 -2
  34. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/goes.py +2 -2
  35. usdata-0.18.0/src/usdata/providers/noaa/grib_index.py +210 -0
  36. usdata-0.18.0/src/usdata/providers/noaa/hrrr.py +438 -0
  37. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/hurdat2.py +2 -2
  38. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/mrms.py +2 -2
  39. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad.py +5 -18
  40. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/spc.py +2 -2
  41. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/storm_events.py +2 -2
  42. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/params.py +18 -0
  43. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/usgs/daily.py +2 -2
  44. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/pull.py +172 -18
  45. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/readers.py +180 -3
  46. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/registry.py +129 -9
  47. usdata-0.18.0/src/usdata/testing.py +475 -0
  48. usdata-0.16.0/src/usdata/__init__.py +0 -55
  49. usdata-0.16.0/src/usdata/_netcdf.py +0 -36
  50. usdata-0.16.0/src/usdata/models.py +0 -256
  51. usdata-0.16.0/src/usdata/protocols/http.py +0 -113
  52. usdata-0.16.0/src/usdata/providers/__init__.py +0 -5
  53. usdata-0.16.0/src/usdata/providers/_http.py +0 -27
  54. usdata-0.16.0/src/usdata/providers/noaa/hrrr.py +0 -224
  55. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_files.py +0 -0
  56. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_hurdat2.py +0 -0
  57. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_progress.py +0 -0
  58. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/_radar.py +0 -0
  59. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/__init__.py +0 -0
  60. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/cli/progress.py +0 -0
  61. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/nexrad_sites.csv +0 -0
  62. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/places.csv +0 -0
  63. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/data/places.sources.json +0 -0
  64. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/__init__.py +0 -0
  65. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/erddap.py +0 -0
  66. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/protocols/listing.py +0 -0
  67. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/__init__.py +0 -0
  68. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsom.py +0 -0
  69. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  70. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/lcd.py +0 -0
  71. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  72. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/normals.py +0 -0
  73. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/noaa/sites.py +0 -0
  74. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/providers/usgs/__init__.py +0 -0
  75. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/py.typed +0 -0
  76. {usdata-0.16.0 → usdata-0.18.0}/src/usdata/query.py +0 -0
  77. {usdata-0.16.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.16.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
@@ -108,7 +108,8 @@ the `check-*` commands install their respective extras.
108
108
 
109
109
  Releases: `just release minor` prepares a release branch. Update release
110
110
  notices, then `just release-pr` validates and opens a draft for review. Merging
111
- the reviewed PR publishes to PyPI and creates the tag and GitHub release. See
111
+ the reviewed PR publishes to PyPI and creates the tag and GitHub release, then
112
+ walks the published package through the getting-started guide. See
112
113
  [versioning](docs/versioning.md).
113
114
 
114
115
  The main way the project grows is a new dataset: one registry entry and one
@@ -74,7 +74,8 @@ the `check-*` commands install their respective extras.
74
74
 
75
75
  Releases: `just release minor` prepares a release branch. Update release
76
76
  notices, then `just release-pr` validates and opens a draft for review. Merging
77
- the reviewed PR publishes to PyPI and creates the tag and GitHub release. See
77
+ the reviewed PR publishes to PyPI and creates the tag and GitHub release, then
78
+ walks the published package through the getting-started guide. See
78
79
  [versioning](docs/versioning.md).
79
80
 
80
81
  The main way the project grows is a new dataset: one registry entry and one
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.16.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.16.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"
@@ -0,0 +1,99 @@
1
+ """usdata: unified access and provenance for U.S. public scientific data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import Any
7
+
8
+ try:
9
+ __version__ = version("usdata")
10
+ except PackageNotFoundError: # running from a source tree without an install
11
+ __version__ = "0.0.0"
12
+
13
+ from usdata._fetch import ChecksumMismatch, FetchedAsset, fetch, fetch_asset
14
+ from usdata.cite import Citation, cite_dataset, cite_lockfile
15
+ from usdata.inspect import inspect_asset, inspect_path
16
+ from usdata.models import Asset, BBox, Dataset, Provenance, Query, TemporalSelection, TimeRange
17
+ from usdata.pull import pull, verify
18
+ from usdata.query import build_query
19
+ from usdata.registry import DatasetNotFound, Registry, SearchResult, default_registry
20
+ from usdata.selection import select_by_time
21
+
22
+ __all__ = [
23
+ "Asset",
24
+ "BBox",
25
+ "ChecksumMismatch",
26
+ "Citation",
27
+ "Dataset",
28
+ "DatasetNotFound",
29
+ "FetchedAsset",
30
+ "Provenance",
31
+ "Query",
32
+ "Registry",
33
+ "SearchResult",
34
+ "TemporalSelection",
35
+ "TimeRange",
36
+ "__version__",
37
+ "build_query",
38
+ "cite_dataset",
39
+ "cite_lockfile",
40
+ "datasets",
41
+ "default_registry",
42
+ "fetch",
43
+ "fetch_asset",
44
+ "get",
45
+ "inspect_asset",
46
+ "inspect_path",
47
+ "pull",
48
+ "search",
49
+ "select_by_time",
50
+ "verify",
51
+ ]
52
+
53
+
54
+ def datasets(
55
+ *,
56
+ provider: str | None = None,
57
+ domain: str | None = None,
58
+ format: str | None = None,
59
+ reader: str | None = None,
60
+ capability: str | None = None,
61
+ status: str = "available",
62
+ ) -> list[Dataset]:
63
+ """List curated registry datasets. Keyword arguments match ``Registry.list``."""
64
+ return default_registry().list(
65
+ provider=provider,
66
+ domain=domain,
67
+ format=format,
68
+ reader=reader,
69
+ capability=capability,
70
+ status=status,
71
+ )
72
+
73
+
74
+ def search(
75
+ text: str | None = None,
76
+ *,
77
+ include_planned: bool = False,
78
+ domain: str | None = None,
79
+ format: str | None = None,
80
+ reader: str | None = None,
81
+ capability: str | None = None,
82
+ status: str | None = None,
83
+ **kwargs: Any,
84
+ ) -> list[SearchResult]:
85
+ """Search the curated registry. Remaining keyword arguments match ``build_query``."""
86
+ return default_registry().search(
87
+ build_query(text, **kwargs),
88
+ include_planned=include_planned,
89
+ domain=domain,
90
+ format=format,
91
+ reader=reader,
92
+ capability=capability,
93
+ status=status,
94
+ )
95
+
96
+
97
+ def get(dataset_id: str) -> Dataset:
98
+ """Look up a dataset by ``provider:name`` id."""
99
+ return default_registry().get(dataset_id)
@@ -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,9 +2,10 @@
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
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -14,6 +15,9 @@ from usdata.cache import asset_path, sha256_file
14
15
  from usdata.models import Asset, Dataset, Provenance, Query
15
16
  from usdata.providers import Provider, load_adapter
16
17
 
18
+ if TYPE_CHECKING:
19
+ from usdata.inspect import Summary
20
+
17
21
 
18
22
  class ChecksumMismatch(RuntimeError):
19
23
  """A fetched file's sha256 did not match the checksum the adapter declared."""
@@ -37,16 +41,21 @@ class FetchedAsset(BaseModel):
37
41
  nrows: int | None = None,
38
42
  sweep: int | list[int] | None = None,
39
43
  select: Mapping[str, Any] | None = None,
44
+ strict: bool = False,
40
45
  ) -> Any:
41
46
  """Open local data with an optional ``pandas``, ``radar``, ``netcdf``, or ``grib`` reader.
42
47
 
43
48
  ERDDAP units are kept in ``frame.attrs["units"]`` and source provenance
44
49
  in ``frame.attrs["usdata"]``. NEXRAD returns a xarray DataTree with provenance
45
50
  in ``radar.attrs["usdata"]``. NetCDF4 and GRIB2 return a loaded xarray Dataset
46
- with matching provenance in its attributes. See ``usdata.readers.open_asset``
47
- for options. Use ``sweep=0`` or ``sweep=[0, 2]`` to load selected zero-based
48
- radar sweeps, and ``select={"shortName": "cape", "typeOfLevel": "surface"}``
49
- to choose GRIB2 messages. Cached files and provenance sidecars are never changed.
51
+ with matching provenance in its attributes, and with units and long names
52
+ the file leaves unstated filled from the registry entry's variables. See
53
+ ``usdata.readers.open_asset`` for options. Use ``sweep=0`` or ``sweep=[0, 2]``
54
+ to load selected zero-based radar sweeps, and
55
+ ``select={"shortName": "cape", "typeOfLevel": "surface"}``
56
+ to choose GRIB2 messages; ``strict=True`` raises instead of warning when a
57
+ GRIB2 select value matches none of the selected messages. Cached files and
58
+ provenance sidecars are never changed.
50
59
  """
51
60
  from usdata.readers import open_asset
52
61
 
@@ -59,8 +68,22 @@ class FetchedAsset(BaseModel):
59
68
  nrows=nrows,
60
69
  sweep=sweep,
61
70
  select=select,
71
+ strict=strict,
62
72
  )
63
73
 
74
+ def inspect(self) -> Summary:
75
+ """Summarize this file: provenance, format, and what that format holds.
76
+
77
+ CSV columns and a capped row count need no extra; NetCDF4 variables and
78
+ GRIB2 messages need the same extras ``open`` does, and a missing one
79
+ yields a summary with no detail and a note naming it. Like ``open``,
80
+ this reads the cached file and changes nothing. See
81
+ ``usdata.inspect.inspect_asset``.
82
+ """
83
+ from usdata.inspect import inspect_asset
84
+
85
+ return inspect_asset(self)
86
+
64
87
 
65
88
  def _sidecar_not_older(path: Path) -> bool:
66
89
  """Whether ``path`` still carries the mtime it had when its sidecar was written.
@@ -83,7 +106,14 @@ def _fetch_asset(
83
106
  *,
84
107
  root: Path | None = None,
85
108
  force: bool = False,
109
+ pinned: Provenance | None = None,
86
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
+ """
87
117
  if asset.dataset_id != dataset.id:
88
118
  raise ValueError(f"asset dataset {asset.dataset_id!r} does not match {dataset.id!r}")
89
119
  _progress.emit(_progress.AssetProgress(asset.id, "start", asset.size))
@@ -106,8 +136,9 @@ def _fetch_asset(
106
136
  _progress.emit(_progress.AssetProgress(asset.id, "cached", prov.size))
107
137
  return FetchedAsset(asset=asset, path=path, provenance=prov, from_cache=True)
108
138
  with staged_path(path) as tmp:
139
+ partial = adapter.prepare_fetch(asset, pinned)
109
140
  adapter.fetch(asset, tmp)
110
- prov = provenance.record(dataset, asset, tmp)
141
+ prov = provenance.record(dataset, asset, tmp, partial)
111
142
  if asset.checksum and prov.checksum != asset.checksum:
112
143
  raise ChecksumMismatch(f"{asset.id}: expected {asset.checksum}, got {prov.checksum}")
113
144
  # A crash between replacements leaves a detectable mismatch, never a trusted partial file.
@@ -117,11 +148,17 @@ def _fetch_asset(
117
148
 
118
149
 
119
150
  def fetch_asset(
120
- 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,
121
156
  ) -> FetchedAsset:
122
157
  """Fetch one asset, reusing the cache only when its provenance still describes it."""
123
158
  with load_adapter(dataset) as adapter:
124
- 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
+ )
125
162
 
126
163
 
127
164
  def _fetch_with(
@@ -139,8 +176,14 @@ def _fetch_with(
139
176
 
140
177
 
141
178
  def fetch(
142
- 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,
143
184
  ) -> list[FetchedAsset]:
144
185
  """Resolve and fetch a query, sharing one adapter and closing its owned resources."""
145
186
  with load_adapter(dataset) as adapter:
146
- 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
@@ -14,14 +16,17 @@ from __future__ import annotations
14
16
 
15
17
  import gzip
16
18
  import re
17
- from collections.abc import Iterator, Mapping
19
+ import warnings
20
+ from collections.abc import Iterator, Mapping, Sequence
18
21
  from contextlib import contextmanager
19
22
  from datetime import UTC, datetime
20
23
  from importlib import import_module
24
+ from inspect import currentframe
21
25
  from pathlib import Path
22
- from typing import TYPE_CHECKING, Any
26
+ from typing import TYPE_CHECKING, Any, NamedTuple
23
27
 
24
- from usdata.readers import MissingReaderDependency
28
+ from usdata.inspect import GribMessage
29
+ from usdata.readers import MissingReaderDependency, fill_registry_attrs
25
30
 
26
31
  if TYPE_CHECKING:
27
32
  from usdata._fetch import FetchedAsset
@@ -33,7 +38,7 @@ LIBRARY_HINT = (
33
38
  "`conda install -c conda-forge eccodes` or `brew install eccodes`"
34
39
  )
35
40
  MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)_\d{2}\.\d{2}_\d{8}-\d{6}\.grib2(?:\.gz)?$")
36
- SUMMARY_KEYS = ("shortName", "typeOfLevel", "level")
41
+ INVENTORY_KEYS = ("shortName", "name", "typeOfLevel", "level", "step", "units")
37
42
  VARIABLE_KEYS = (
38
43
  "name",
39
44
  "units",
@@ -129,28 +134,98 @@ def _get(eccodes: Any, h: int, key: str, ktype: type | None = None) -> Any:
129
134
  return None
130
135
 
131
136
 
132
- def _matches(eccodes: Any, h: int, select: Mapping[str, Any]) -> bool:
133
- for key, wanted in select.items():
134
- options = wanted if isinstance(wanted, list | tuple | set) else [wanted]
135
- if not options:
136
- raise ValueError(f"select[{key!r}] must not be empty")
137
- text = _get(eccodes, h, key, str)
138
- number = _get(eccodes, h, key, float) if text is not None else None
139
- matched = False
140
- for option in options:
141
- if isinstance(option, bool) or not isinstance(option, int | float | str):
142
- raise ValueError(f"select[{key!r}] values must be strings or numbers")
143
- if isinstance(option, str):
144
- matched |= text == option
145
- elif number is not None:
146
- matched |= number == option
147
- if not matched:
148
- return False
149
- return True
137
+ def _options(key: str, wanted: Any) -> list[Any]:
138
+ """One key's wanted values as a validated list, whether one value or several were given."""
139
+ options = list(wanted) if isinstance(wanted, list | tuple | set) else [wanted]
140
+ if not options:
141
+ raise ValueError(f"select[{key!r}] must not be empty")
142
+ for option in options:
143
+ if isinstance(option, bool) or not isinstance(option, int | float | str):
144
+ raise ValueError(f"select[{key!r}] values must be strings or numbers")
145
+ return options
150
146
 
151
147
 
152
- def _summary(eccodes: Any, h: int) -> tuple[str, str, Any]:
153
- return tuple(_get(eccodes, h, key, str) for key in SUMMARY_KEYS) # type: ignore[return-value]
148
+ def _matched(eccodes: Any, h: int, key: str, options: list[Any]) -> set[int]:
149
+ """Positions in ``options`` that this message's value for ``key`` matches."""
150
+ text = _get(eccodes, h, key, str)
151
+ number = _get(eccodes, h, key, float) if text is not None else None
152
+ hits = set()
153
+ for index, option in enumerate(options):
154
+ if isinstance(option, str):
155
+ if text == option:
156
+ hits.add(index)
157
+ elif number is not None and number == option:
158
+ hits.add(index)
159
+ return hits
160
+
161
+
162
+ def _unmatched_report(
163
+ options: Mapping[str, list[Any]],
164
+ matched: Mapping[str, set[int]],
165
+ present: Mapping[str, list[str]],
166
+ ) -> str:
167
+ """Text naming every select value that matched no message, with what was there instead."""
168
+ parts = []
169
+ for key, wanted in options.items():
170
+ missing = [option for index, option in enumerate(wanted) if index not in matched[key]]
171
+ if not missing:
172
+ continue
173
+ available = ", ".join(repr(value) for value in present[key]) or "none"
174
+ parts.append(
175
+ f"select[{key!r}] matched no message for "
176
+ f"{', '.join(repr(option) for option in missing)}; "
177
+ f"{key} values available with the other select keys: {available}."
178
+ )
179
+ return " ".join(parts)
180
+
181
+
182
+ def _caller_stacklevel() -> int:
183
+ """Stack level of the first frame outside usdata, so a warning points at the caller."""
184
+ package = Path(__file__).resolve().parent
185
+ frame = currentframe()
186
+ frame = frame.f_back if frame is not None else None
187
+ level = 1
188
+ while frame is not None and Path(frame.f_code.co_filename).resolve().is_relative_to(package):
189
+ level += 1
190
+ frame = frame.f_back
191
+ return level
192
+
193
+
194
+ def _shape(eccodes: Any, h: int) -> tuple[int, int] | None:
195
+ """A message's grid rows and columns under either key pair, or None when neither is set."""
196
+ rows, cols = _get(eccodes, h, "Nj", int), _get(eccodes, h, "Ni", int)
197
+ if rows is None or cols is None:
198
+ rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
199
+ return None if rows is None or cols is None else (rows, cols)
200
+
201
+
202
+ def inventory(path: Path) -> list[GribMessage]:
203
+ """Every message in a local GRIB2 file, in file order, without decoding its values."""
204
+ eccodes = _modules()[0]
205
+ messages = []
206
+ for index, h in enumerate(_messages(eccodes, path)):
207
+ keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
208
+ messages.append(
209
+ GribMessage(
210
+ file_index=index,
211
+ short_name=keys["shortName"],
212
+ name=keys["name"],
213
+ type_of_level=keys["typeOfLevel"],
214
+ level=keys["level"],
215
+ step=keys["step"],
216
+ units=keys["units"],
217
+ shape=_shape(eccodes, h),
218
+ )
219
+ )
220
+ return messages
221
+
222
+
223
+ def _available(path: Path) -> str:
224
+ """Every message as a (shortName, typeOfLevel, level) triple, for a reader error to list."""
225
+ return ", ".join(
226
+ f"({message.short_name!r}, {message.type_of_level!r}, {message.level!r})"
227
+ for message in inventory(path)
228
+ )
154
229
 
155
230
 
156
231
  def _product_name(asset_id: str) -> str | None:
@@ -176,13 +251,11 @@ class _Grid:
176
251
 
177
252
  def __init__(self, eccodes: Any, numpy: Any, h: int) -> None:
178
253
  self.grid_type = _get(eccodes, h, "gridType", str) or "unknown"
179
- rows = _get(eccodes, h, "Nj", int)
180
- cols = _get(eccodes, h, "Ni", int)
181
- if rows is None or cols is None:
182
- rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
183
- if rows is None or cols is None:
254
+ shape = _shape(eccodes, h)
255
+ if shape is None:
184
256
  raise ValueError(f"cannot determine the grid shape of a {self.grid_type} message")
185
- self.shape = (rows, cols)
257
+ self.shape = shape
258
+ rows, cols = shape
186
259
  self.flip_rows = bool(_get(eccodes, h, "jScansPositively", int))
187
260
  self.flip_cols = bool(_get(eccodes, h, "iScansNegatively", int))
188
261
  if _get(eccodes, h, "jPointsAreConsecutive", int):
@@ -227,21 +300,96 @@ class _Grid:
227
300
  return numpy.ascontiguousarray(grid)
228
301
 
229
302
 
230
- def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -> Any:
231
- """Decode selected GRIB2 messages into one loaded xarray Dataset."""
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
+
341
+ def open_grib2(
342
+ fetched: FetchedAsset, select: Mapping[str, Any] | None = None, *, strict: bool = False
343
+ ) -> Any:
344
+ """Decode selected GRIB2 messages into one loaded xarray Dataset.
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
+
361
+ A select value that matches none of the selected messages warns, or raises
362
+ ``ValueError`` when ``strict``; a select that matches nothing always raises.
363
+ """
232
364
  eccodes, xarray, numpy = _modules()
233
365
  if select is not None and not isinstance(select, Mapping):
234
366
  raise ValueError("select must be a mapping of ecCodes key names to values")
235
- fields: dict[str, tuple[Any, dict[str, Any]]] = {}
367
+ options = {key: _options(key, wanted) for key, wanted in (select or {}).items()}
368
+ matched: dict[str, set[int]] = {key: set() for key in options}
369
+ present: dict[str, list[str]] = {key: [] for key in options}
370
+ selected: list[_Field] = []
236
371
  grid: _Grid | None = None
237
- available: list[tuple[str, str, Any]] = []
238
- names: list[str] = []
372
+ count = 0
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 []
239
376
  for h in _messages(eccodes, fetched.path):
240
- available.append(_summary(eccodes, h))
241
- if len(available) > 1 and select is None:
377
+ count += 1
378
+ if count > 1 and needs_select:
242
379
  continue
243
- if select is not None and not _matches(eccodes, h, select):
380
+ hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
381
+ missed = [key for key, indexes in hits.items() if not indexes]
382
+ for key in options:
383
+ # What this key could have been with the rest of the select held fixed.
384
+ if missed not in ([], [key]):
385
+ continue
386
+ value = _get(eccodes, h, key, str)
387
+ if value is not None and value not in present[key]:
388
+ present[key].append(value)
389
+ if missed:
244
390
  continue
391
+ for key, indexes in hits.items():
392
+ matched[key] |= indexes
245
393
  message_grid = _Grid(eccodes, numpy, h)
246
394
  if grid is None:
247
395
  grid = message_grid
@@ -266,37 +414,51 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
266
414
  stamp = _time(attrs.get(date_key), attrs.get(time_key))
267
415
  if stamp:
268
416
  attrs[label] = stamp
269
- 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"]
270
428
  if short in (None, "", "unknown", "~"):
271
429
  short = _product_name(fetched.asset.id) or (
272
430
  f"parameter_{attrs.get('discipline')}_{attrs.get('parameterCategory')}"
273
431
  f"_{attrs.get('parameterNumber')}"
274
432
  )
275
- names.append(short)
276
- fields[f"{short}#{len(names)}"] = (data, attrs)
277
- if len(available) > 1 and select is None:
278
- listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
433
+ selected.append(_Field(short=short, data=data, attrs=attrs, message=message))
434
+ if count > 1 and needs_select:
279
435
  raise ValueError(
280
- f"{len(available)} messages; pass select={{...}} with ecCodes keys to choose, "
436
+ f"{count} messages; pass select={{...}} with ecCodes keys to choose, "
281
437
  f"for example select={{'shortName': ..., 'typeOfLevel': ...}}. "
282
- f"Available (shortName, typeOfLevel, level): {listing}"
438
+ f"Available (shortName, typeOfLevel, level): {_available(fetched.path)}"
283
439
  )
284
- if grid is None or not fields:
285
- if not available:
440
+ if grid is None or not selected:
441
+ if not count:
286
442
  raise ValueError("no GRIB2 messages found")
287
- listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
288
443
  raise ValueError(
289
- f"select matched no messages. Available (shortName, typeOfLevel, level): {listing}"
444
+ "select matched no messages. Available (shortName, typeOfLevel, level): "
445
+ f"{_available(fetched.path)}"
290
446
  )
447
+ if report := _unmatched_report(options, matched, present):
448
+ if strict:
449
+ raise ValueError(report)
450
+ warnings.warn(report, UserWarning, stacklevel=_caller_stacklevel())
291
451
  variables: dict[str, Any] = {}
292
- duplicates = {name for name in names if names.count(name) > 1}
293
- for (data, attrs), name in zip(fields.values(), names, strict=True):
294
- label = name
295
- if name in duplicates:
296
- label = f"{name}_{attrs.get('typeOfLevel', 'level')}_{attrs.get('level', '')}"
297
- if label in variables:
298
- label = f"{label}_{len(variables)}"
299
- 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
300
462
  dataset = xarray.Dataset(variables, coords=grid.coords)
301
463
  dataset.latitude.attrs["units"] = "degrees_north"
302
464
  dataset.longitude.attrs["units"] = "degrees_east"
@@ -304,5 +466,7 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
304
466
  dataset.attrs["usdata"] = {
305
467
  "asset_id": fetched.asset.id,
306
468
  "provenance": fetched.provenance.model_dump(mode="json"),
469
+ "messages": messages,
307
470
  }
471
+ fill_registry_attrs(fetched, dataset)
308
472
  return dataset