usdata 0.16.0__tar.gz → 0.17.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.16.0 → usdata-0.17.0}/PKG-INFO +3 -2
  2. {usdata-0.16.0 → usdata-0.17.0}/README.md +2 -1
  3. {usdata-0.16.0 → usdata-0.17.0}/pyproject.toml +1 -1
  4. {usdata-0.16.0 → usdata-0.17.0}/pyproject.toml.orig +1 -1
  5. usdata-0.17.0/src/usdata/__init__.py +98 -0
  6. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_fetch.py +27 -5
  7. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_grib.py +136 -41
  8. usdata-0.17.0/src/usdata/_netcdf.py +69 -0
  9. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cache.py +21 -4
  10. usdata-0.17.0/src/usdata/cache_ops.py +249 -0
  11. usdata-0.17.0/src/usdata/cite.py +197 -0
  12. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/app.py +201 -21
  13. usdata-0.17.0/src/usdata/cli/cache.py +132 -0
  14. usdata-0.17.0/src/usdata/cli/cite.py +76 -0
  15. usdata-0.17.0/src/usdata/cli/doctor.py +33 -0
  16. usdata-0.17.0/src/usdata/cli/inspect.py +147 -0
  17. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/registry.yaml +635 -255
  18. usdata-0.17.0/src/usdata/doctor.py +229 -0
  19. usdata-0.17.0/src/usdata/inspect.py +246 -0
  20. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/manifest.py +26 -0
  21. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/models.py +183 -1
  22. usdata-0.17.0/src/usdata/providers/__init__.py +37 -0
  23. usdata-0.17.0/src/usdata/providers/http.py +44 -0
  24. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/coastwatch.py +2 -2
  25. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/coops.py +2 -2
  26. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/ghcnd.py +2 -2
  27. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/glm.py +2 -2
  28. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/goes.py +2 -2
  29. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/hrrr.py +2 -2
  30. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/hurdat2.py +2 -2
  31. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/mrms.py +2 -2
  32. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/nexrad.py +5 -18
  33. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/spc.py +2 -2
  34. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/storm_events.py +2 -2
  35. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/params.py +18 -0
  36. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/usgs/daily.py +2 -2
  37. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/pull.py +47 -7
  38. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/readers.py +113 -3
  39. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/registry.py +126 -8
  40. usdata-0.17.0/src/usdata/testing.py +460 -0
  41. usdata-0.16.0/src/usdata/__init__.py +0 -55
  42. usdata-0.16.0/src/usdata/_netcdf.py +0 -36
  43. usdata-0.16.0/src/usdata/providers/__init__.py +0 -5
  44. usdata-0.16.0/src/usdata/providers/_http.py +0 -27
  45. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_files.py +0 -0
  46. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_hurdat2.py +0 -0
  47. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_progress.py +0 -0
  48. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/_radar.py +0 -0
  49. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/__init__.py +0 -0
  50. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/cli/progress.py +0 -0
  51. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/nexrad_sites.csv +0 -0
  52. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/places.csv +0 -0
  53. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/data/places.sources.json +0 -0
  54. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/__init__.py +0 -0
  55. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/erddap.py +0 -0
  56. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/http.py +0 -0
  57. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/listing.py +0 -0
  58. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/protocols/s3.py +0 -0
  59. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/provenance.py +0 -0
  60. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/base.py +0 -0
  61. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/__init__.py +0 -0
  62. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gfs.py +0 -0
  63. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gsom.py +0 -0
  64. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  65. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/lcd.py +0 -0
  66. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  67. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/normals.py +0 -0
  68. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/noaa/sites.py +0 -0
  69. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/providers/usgs/__init__.py +0 -0
  70. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/py.typed +0 -0
  71. {usdata-0.16.0 → usdata-0.17.0}/src/usdata/query.py +0 -0
  72. {usdata-0.16.0 → usdata-0.17.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.17.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.17.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.17.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,98 @@
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 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
+ "Dataset",
27
+ "DatasetNotFound",
28
+ "FetchedAsset",
29
+ "Provenance",
30
+ "Query",
31
+ "Registry",
32
+ "SearchResult",
33
+ "TemporalSelection",
34
+ "TimeRange",
35
+ "__version__",
36
+ "build_query",
37
+ "cite_dataset",
38
+ "cite_lockfile",
39
+ "datasets",
40
+ "default_registry",
41
+ "fetch",
42
+ "fetch_asset",
43
+ "get",
44
+ "inspect_asset",
45
+ "inspect_path",
46
+ "pull",
47
+ "search",
48
+ "select_by_time",
49
+ "verify",
50
+ ]
51
+
52
+
53
+ def datasets(
54
+ *,
55
+ provider: str | None = None,
56
+ domain: str | None = None,
57
+ format: str | None = None,
58
+ reader: str | None = None,
59
+ capability: str | None = None,
60
+ status: str = "available",
61
+ ) -> list[Dataset]:
62
+ """List curated registry datasets. Keyword arguments match ``Registry.list``."""
63
+ return default_registry().list(
64
+ provider=provider,
65
+ domain=domain,
66
+ format=format,
67
+ reader=reader,
68
+ capability=capability,
69
+ status=status,
70
+ )
71
+
72
+
73
+ def search(
74
+ text: str | None = None,
75
+ *,
76
+ include_planned: bool = False,
77
+ domain: str | None = None,
78
+ format: str | None = None,
79
+ reader: str | None = None,
80
+ capability: str | None = None,
81
+ status: str | None = None,
82
+ **kwargs: Any,
83
+ ) -> list[SearchResult]:
84
+ """Search the curated registry. Remaining keyword arguments match ``build_query``."""
85
+ return default_registry().search(
86
+ build_query(text, **kwargs),
87
+ include_planned=include_planned,
88
+ domain=domain,
89
+ format=format,
90
+ reader=reader,
91
+ capability=capability,
92
+ status=status,
93
+ )
94
+
95
+
96
+ def get(dataset_id: str) -> Dataset:
97
+ """Look up a dataset by ``provider:name`` id."""
98
+ return default_registry().get(dataset_id)
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Mapping
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
@@ -14,6 +14,9 @@ from usdata.cache import asset_path, sha256_file
14
14
  from usdata.models import Asset, Dataset, Provenance, Query
15
15
  from usdata.providers import Provider, load_adapter
16
16
 
17
+ if TYPE_CHECKING:
18
+ from usdata.inspect import Summary
19
+
17
20
 
18
21
  class ChecksumMismatch(RuntimeError):
19
22
  """A fetched file's sha256 did not match the checksum the adapter declared."""
@@ -37,16 +40,21 @@ class FetchedAsset(BaseModel):
37
40
  nrows: int | None = None,
38
41
  sweep: int | list[int] | None = None,
39
42
  select: Mapping[str, Any] | None = None,
43
+ strict: bool = False,
40
44
  ) -> Any:
41
45
  """Open local data with an optional ``pandas``, ``radar``, ``netcdf``, or ``grib`` reader.
42
46
 
43
47
  ERDDAP units are kept in ``frame.attrs["units"]`` and source provenance
44
48
  in ``frame.attrs["usdata"]``. NEXRAD returns a xarray DataTree with provenance
45
49
  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.
50
+ with matching provenance in its attributes, and with units and long names
51
+ the file leaves unstated filled from the registry entry's variables. See
52
+ ``usdata.readers.open_asset`` for options. Use ``sweep=0`` or ``sweep=[0, 2]``
53
+ to load selected zero-based radar sweeps, and
54
+ ``select={"shortName": "cape", "typeOfLevel": "surface"}``
55
+ to choose GRIB2 messages; ``strict=True`` raises instead of warning when a
56
+ GRIB2 select value matches none of the selected messages. Cached files and
57
+ provenance sidecars are never changed.
50
58
  """
51
59
  from usdata.readers import open_asset
52
60
 
@@ -59,8 +67,22 @@ class FetchedAsset(BaseModel):
59
67
  nrows=nrows,
60
68
  sweep=sweep,
61
69
  select=select,
70
+ strict=strict,
62
71
  )
63
72
 
73
+ def inspect(self) -> Summary:
74
+ """Summarize this file: provenance, format, and what that format holds.
75
+
76
+ CSV columns and a capped row count need no extra; NetCDF4 variables and
77
+ GRIB2 messages need the same extras ``open`` does, and a missing one
78
+ yields a summary with no detail and a note naming it. Like ``open``,
79
+ this reads the cached file and changes nothing. See
80
+ ``usdata.inspect.inspect_asset``.
81
+ """
82
+ from usdata.inspect import inspect_asset
83
+
84
+ return inspect_asset(self)
85
+
64
86
 
65
87
  def _sidecar_not_older(path: Path) -> bool:
66
88
  """Whether ``path`` still carries the mtime it had when its sidecar was written.
@@ -14,14 +14,17 @@ from __future__ import annotations
14
14
 
15
15
  import gzip
16
16
  import re
17
+ import warnings
17
18
  from collections.abc import Iterator, Mapping
18
19
  from contextlib import contextmanager
19
20
  from datetime import UTC, datetime
20
21
  from importlib import import_module
22
+ from inspect import currentframe
21
23
  from pathlib import Path
22
24
  from typing import TYPE_CHECKING, Any
23
25
 
24
- from usdata.readers import MissingReaderDependency
26
+ from usdata.inspect import GribMessage
27
+ from usdata.readers import MissingReaderDependency, fill_registry_attrs
25
28
 
26
29
  if TYPE_CHECKING:
27
30
  from usdata._fetch import FetchedAsset
@@ -33,7 +36,7 @@ LIBRARY_HINT = (
33
36
  "`conda install -c conda-forge eccodes` or `brew install eccodes`"
34
37
  )
35
38
  MRMS_NAME = re.compile(r"^MRMS_(?P<product>.+?)_\d{2}\.\d{2}_\d{8}-\d{6}\.grib2(?:\.gz)?$")
36
- SUMMARY_KEYS = ("shortName", "typeOfLevel", "level")
39
+ INVENTORY_KEYS = ("shortName", "name", "typeOfLevel", "level", "step", "units")
37
40
  VARIABLE_KEYS = (
38
41
  "name",
39
42
  "units",
@@ -129,28 +132,98 @@ def _get(eccodes: Any, h: int, key: str, ktype: type | None = None) -> Any:
129
132
  return None
130
133
 
131
134
 
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
135
+ def _options(key: str, wanted: Any) -> list[Any]:
136
+ """One key's wanted values as a validated list, whether one value or several were given."""
137
+ options = list(wanted) if isinstance(wanted, list | tuple | set) else [wanted]
138
+ if not options:
139
+ raise ValueError(f"select[{key!r}] must not be empty")
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
+ return options
150
144
 
151
145
 
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]
146
+ def _matched(eccodes: Any, h: int, key: str, options: list[Any]) -> set[int]:
147
+ """Positions in ``options`` that this message's value for ``key`` matches."""
148
+ text = _get(eccodes, h, key, str)
149
+ number = _get(eccodes, h, key, float) if text is not None else None
150
+ hits = set()
151
+ for index, option in enumerate(options):
152
+ if isinstance(option, str):
153
+ if text == option:
154
+ hits.add(index)
155
+ elif number is not None and number == option:
156
+ hits.add(index)
157
+ return hits
158
+
159
+
160
+ def _unmatched_report(
161
+ options: Mapping[str, list[Any]],
162
+ matched: Mapping[str, set[int]],
163
+ present: Mapping[str, list[str]],
164
+ ) -> str:
165
+ """Text naming every select value that matched no message, with what was there instead."""
166
+ parts = []
167
+ for key, wanted in options.items():
168
+ missing = [option for index, option in enumerate(wanted) if index not in matched[key]]
169
+ if not missing:
170
+ continue
171
+ available = ", ".join(repr(value) for value in present[key]) or "none"
172
+ parts.append(
173
+ f"select[{key!r}] matched no message for "
174
+ f"{', '.join(repr(option) for option in missing)}; "
175
+ f"{key} values available with the other select keys: {available}."
176
+ )
177
+ return " ".join(parts)
178
+
179
+
180
+ def _caller_stacklevel() -> int:
181
+ """Stack level of the first frame outside usdata, so a warning points at the caller."""
182
+ package = Path(__file__).resolve().parent
183
+ frame = currentframe()
184
+ frame = frame.f_back if frame is not None else None
185
+ level = 1
186
+ while frame is not None and Path(frame.f_code.co_filename).resolve().is_relative_to(package):
187
+ level += 1
188
+ frame = frame.f_back
189
+ return level
190
+
191
+
192
+ def _shape(eccodes: Any, h: int) -> tuple[int, int] | None:
193
+ """A message's grid rows and columns under either key pair, or None when neither is set."""
194
+ rows, cols = _get(eccodes, h, "Nj", int), _get(eccodes, h, "Ni", int)
195
+ if rows is None or cols is None:
196
+ rows, cols = _get(eccodes, h, "Ny", int), _get(eccodes, h, "Nx", int)
197
+ return None if rows is None or cols is None else (rows, cols)
198
+
199
+
200
+ def inventory(path: Path) -> list[GribMessage]:
201
+ """Every message in a local GRIB2 file, in file order, without decoding its values."""
202
+ eccodes = _modules()[0]
203
+ messages = []
204
+ for index, h in enumerate(_messages(eccodes, path)):
205
+ keys = {key: _get(eccodes, h, key, str) for key in INVENTORY_KEYS}
206
+ messages.append(
207
+ GribMessage(
208
+ index=index,
209
+ short_name=keys["shortName"],
210
+ name=keys["name"],
211
+ type_of_level=keys["typeOfLevel"],
212
+ level=keys["level"],
213
+ step=keys["step"],
214
+ units=keys["units"],
215
+ shape=_shape(eccodes, h),
216
+ )
217
+ )
218
+ return messages
219
+
220
+
221
+ def _available(path: Path) -> str:
222
+ """Every message as a (shortName, typeOfLevel, level) triple, for a reader error to list."""
223
+ return ", ".join(
224
+ f"({message.short_name!r}, {message.type_of_level!r}, {message.level!r})"
225
+ for message in inventory(path)
226
+ )
154
227
 
155
228
 
156
229
  def _product_name(asset_id: str) -> str | None:
@@ -176,13 +249,11 @@ class _Grid:
176
249
 
177
250
  def __init__(self, eccodes: Any, numpy: Any, h: int) -> None:
178
251
  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:
252
+ shape = _shape(eccodes, h)
253
+ if shape is None:
184
254
  raise ValueError(f"cannot determine the grid shape of a {self.grid_type} message")
185
- self.shape = (rows, cols)
255
+ self.shape = shape
256
+ rows, cols = shape
186
257
  self.flip_rows = bool(_get(eccodes, h, "jScansPositively", int))
187
258
  self.flip_cols = bool(_get(eccodes, h, "iScansNegatively", int))
188
259
  if _get(eccodes, h, "jPointsAreConsecutive", int):
@@ -227,21 +298,41 @@ class _Grid:
227
298
  return numpy.ascontiguousarray(grid)
228
299
 
229
300
 
230
- def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -> Any:
231
- """Decode selected GRIB2 messages into one loaded xarray Dataset."""
301
+ def open_grib2(
302
+ fetched: FetchedAsset, select: Mapping[str, Any] | None = None, *, strict: bool = False
303
+ ) -> Any:
304
+ """Decode selected GRIB2 messages into one loaded xarray Dataset.
305
+
306
+ A select value that matches none of the selected messages warns, or raises
307
+ ``ValueError`` when ``strict``; a select that matches nothing always raises.
308
+ """
232
309
  eccodes, xarray, numpy = _modules()
233
310
  if select is not None and not isinstance(select, Mapping):
234
311
  raise ValueError("select must be a mapping of ecCodes key names to values")
312
+ options = {key: _options(key, wanted) for key, wanted in (select or {}).items()}
313
+ matched: dict[str, set[int]] = {key: set() for key in options}
314
+ present: dict[str, list[str]] = {key: [] for key in options}
235
315
  fields: dict[str, tuple[Any, dict[str, Any]]] = {}
236
316
  grid: _Grid | None = None
237
- available: list[tuple[str, str, Any]] = []
317
+ count = 0
238
318
  names: list[str] = []
239
319
  for h in _messages(eccodes, fetched.path):
240
- available.append(_summary(eccodes, h))
241
- if len(available) > 1 and select is None:
320
+ count += 1
321
+ if count > 1 and select is None:
242
322
  continue
243
- if select is not None and not _matches(eccodes, h, select):
323
+ hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
324
+ missed = [key for key, indexes in hits.items() if not indexes]
325
+ for key in options:
326
+ # What this key could have been with the rest of the select held fixed.
327
+ if missed not in ([], [key]):
328
+ continue
329
+ value = _get(eccodes, h, key, str)
330
+ if value is not None and value not in present[key]:
331
+ present[key].append(value)
332
+ if missed:
244
333
  continue
334
+ for key, indexes in hits.items():
335
+ matched[key] |= indexes
245
336
  message_grid = _Grid(eccodes, numpy, h)
246
337
  if grid is None:
247
338
  grid = message_grid
@@ -274,20 +365,23 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
274
365
  )
275
366
  names.append(short)
276
367
  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)
368
+ if count > 1 and select is None:
279
369
  raise ValueError(
280
- f"{len(available)} messages; pass select={{...}} with ecCodes keys to choose, "
370
+ f"{count} messages; pass select={{...}} with ecCodes keys to choose, "
281
371
  f"for example select={{'shortName': ..., 'typeOfLevel': ...}}. "
282
- f"Available (shortName, typeOfLevel, level): {listing}"
372
+ f"Available (shortName, typeOfLevel, level): {_available(fetched.path)}"
283
373
  )
284
374
  if grid is None or not fields:
285
- if not available:
375
+ if not count:
286
376
  raise ValueError("no GRIB2 messages found")
287
- listing = ", ".join(f"({s!r}, {t!r}, {lv!r})" for s, t, lv in available)
288
377
  raise ValueError(
289
- f"select matched no messages. Available (shortName, typeOfLevel, level): {listing}"
378
+ "select matched no messages. Available (shortName, typeOfLevel, level): "
379
+ f"{_available(fetched.path)}"
290
380
  )
381
+ if report := _unmatched_report(options, matched, present):
382
+ if strict:
383
+ raise ValueError(report)
384
+ warnings.warn(report, UserWarning, stacklevel=_caller_stacklevel())
291
385
  variables: dict[str, Any] = {}
292
386
  duplicates = {name for name in names if names.count(name) > 1}
293
387
  for (data, attrs), name in zip(fields.values(), names, strict=True):
@@ -305,4 +399,5 @@ def open_grib2(fetched: FetchedAsset, select: Mapping[str, Any] | None = None) -
305
399
  "asset_id": fetched.asset.id,
306
400
  "provenance": fetched.provenance.model_dump(mode="json"),
307
401
  }
402
+ fill_registry_attrs(fetched, dataset)
308
403
  return dataset
@@ -0,0 +1,69 @@
1
+ """Local, eagerly loaded NetCDF4 reading behind the netcdf extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from usdata.inspect import NetcdfVariable
10
+ from usdata.readers import MissingReaderDependency, fill_registry_attrs
11
+
12
+ if TYPE_CHECKING:
13
+ from usdata._fetch import FetchedAsset
14
+
15
+ PIP_HINT = 'NetCDF4 reading requires xarray and h5netcdf; install: pip install "usdata[netcdf]"'
16
+
17
+
18
+ def _xarray() -> Any:
19
+ """The xarray module once h5netcdf can back it, or a hint naming the extra."""
20
+ try:
21
+ xarray = import_module("xarray")
22
+ import_module("h5netcdf")
23
+ import_module("h5py")
24
+ except ModuleNotFoundError as error:
25
+ if error.name not in {"xarray", "h5netcdf", "h5py"}:
26
+ raise
27
+ raise MissingReaderDependency(PIP_HINT) from error
28
+ return xarray
29
+
30
+
31
+ def _text(value: Any) -> str | None:
32
+ """One attribute as a single string, or None where the file states nothing."""
33
+ return None if value is None else str(value)
34
+
35
+
36
+ def open_netcdf(fetched: FetchedAsset) -> Any:
37
+ """Load a NetCDF4 root Dataset, then close every source file handle."""
38
+ xarray = _xarray()
39
+ # A local file object and fixed engine prevent interpretation as an OPeNDAP URL.
40
+ with (
41
+ fetched.path.open("rb") as stream,
42
+ xarray.open_dataset(stream, engine="h5netcdf", chunks=None) as dataset,
43
+ ):
44
+ dataset.load()
45
+ dataset.attrs["usdata"] = {
46
+ "asset_id": fetched.asset.id,
47
+ "provenance": fetched.provenance.model_dump(mode="json"),
48
+ }
49
+ fill_registry_attrs(fetched, dataset)
50
+ return dataset
51
+
52
+
53
+ def variables(path: Path) -> list[NetcdfVariable]:
54
+ """Describe a local NetCDF4 file's data variables without loading their values."""
55
+ xarray = _xarray()
56
+ with (
57
+ path.open("rb") as stream,
58
+ xarray.open_dataset(stream, engine="h5netcdf", chunks=None) as dataset,
59
+ ):
60
+ return [
61
+ NetcdfVariable(
62
+ name=str(name),
63
+ dims=[str(dim) for dim in array.dims],
64
+ shape=[int(size) for size in array.shape],
65
+ units=_text(array.attrs.get("units")),
66
+ long_name=_text(array.attrs.get("long_name")),
67
+ )
68
+ for name, array in dataset.data_vars.items()
69
+ ]
@@ -23,14 +23,31 @@ def cache_dir() -> Path:
23
23
 
24
24
  def asset_path(asset: Asset, root: Path | None = None) -> Path:
25
25
  """Where an asset lives in the cache: <root>/<provider>/<dataset>/<asset id>."""
26
- provider, sep, name = asset.dataset_id.partition(":")
26
+ return cached_path(asset.dataset_id, asset.id, root)
27
+
28
+
29
+ def cached_path(dataset_id: str, asset_id: str, root: Path | None = None) -> Path:
30
+ """Where the cache keeps one asset, addressed by its ids rather than the asset itself.
31
+
32
+ Args:
33
+ dataset_id: The owning dataset's ``provider:name`` id.
34
+ asset_id: The asset's id; slashes become underscores, as they do on fetch.
35
+ root: Cache root to resolve against, or the configured cache directory.
36
+
37
+ Returns:
38
+ The path the cache uses for that asset, whether or not it exists.
39
+
40
+ Raises:
41
+ ValueError: Either id is unsafe, or the path would escape the cache root.
42
+ """
43
+ provider, sep, name = dataset_id.partition(":")
27
44
  if not sep or any(
28
45
  not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in (provider, name)
29
46
  ):
30
- raise ValueError(f"unsafe dataset id: {asset.dataset_id!r}")
31
- safe_id = asset.id.replace("/", "_")
47
+ raise ValueError(f"unsafe dataset id: {dataset_id!r}")
48
+ safe_id = asset_id.replace("/", "_")
32
49
  if not safe_id or safe_id in {".", ".."} or "\\" in safe_id or "\x00" in safe_id:
33
- raise ValueError(f"unsafe asset id: {asset.id!r}")
50
+ raise ValueError(f"unsafe asset id: {asset_id!r}")
34
51
  base = (root or cache_dir()).expanduser().resolve()
35
52
  path = base / provider / name / safe_id
36
53
  for candidate in (path, path.with_name(path.name + ".provenance.json")):