usdata 0.31.0__tar.gz → 0.33.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 (89) hide show
  1. {usdata-0.31.0 → usdata-0.33.0}/PKG-INFO +2 -2
  2. {usdata-0.31.0 → usdata-0.33.0}/README.md +1 -1
  3. {usdata-0.31.0 → usdata-0.33.0}/pyproject.toml +1 -1
  4. {usdata-0.31.0 → usdata-0.33.0}/pyproject.toml.orig +1 -1
  5. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/__init__.py +5 -1
  6. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_grib.py +29 -3
  7. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/app.py +169 -23
  8. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/data/registry.yaml +127 -7
  9. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/doctor.py +3 -2
  10. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/inspect.py +18 -15
  11. usdata-0.33.0/src/usdata/messages.py +60 -0
  12. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/models.py +67 -0
  13. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/provenance.py +1 -0
  14. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/base.py +31 -1
  15. usdata-0.33.0/src/usdata/providers/noaa/enso.py +76 -0
  16. usdata-0.33.0/src/usdata/providers/noaa/ersst.py +95 -0
  17. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/grib_index.py +24 -37
  18. usdata-0.33.0/src/usdata/providers/noaa/gsom_files.py +83 -0
  19. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/hrrr.py +18 -1
  20. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/pull.py +136 -6
  21. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/testing.py +8 -4
  22. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/__main__.py +0 -0
  23. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_aqs.py +0 -0
  24. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_census.py +0 -0
  25. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_fetch.py +0 -0
  26. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_files.py +0 -0
  27. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_hurdat2.py +0 -0
  28. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_netcdf.py +0 -0
  29. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_progress.py +0 -0
  30. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/_radar.py +0 -0
  31. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cache.py +0 -0
  32. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cache_ops.py +0 -0
  33. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cite.py +0 -0
  34. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/__init__.py +0 -0
  35. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/cache.py +0 -0
  36. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/cite.py +0 -0
  37. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/doctor.py +0 -0
  38. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/inspect.py +0 -0
  39. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/cli/progress.py +0 -0
  40. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/data/nexrad_sites.csv +0 -0
  41. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/data/places.csv +0 -0
  42. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/data/places.sources.json +0 -0
  43. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/manifest.py +0 -0
  44. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/mirror.py +0 -0
  45. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/protocols/__init__.py +0 -0
  46. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/protocols/erddap.py +0 -0
  47. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/protocols/http.py +0 -0
  48. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/protocols/listing.py +0 -0
  49. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/protocols/s3.py +0 -0
  50. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/__init__.py +0 -0
  51. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/census/__init__.py +0 -0
  52. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/census/acs.py +0 -0
  53. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/credentials.py +0 -0
  54. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/epa/__init__.py +0 -0
  55. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/epa/aqs.py +0 -0
  56. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/fema/__init__.py +0 -0
  57. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/fema/declarations.py +0 -0
  58. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/http.py +0 -0
  59. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/__init__.py +0 -0
  60. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/coastwatch.py +0 -0
  61. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/coops.py +0 -0
  62. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/gfs.py +0 -0
  63. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/ghcnd.py +0 -0
  64. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/glm.py +0 -0
  65. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/goes.py +0 -0
  66. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/gsom.py +0 -0
  67. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/gsoy.py +0 -0
  68. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/hurdat2.py +0 -0
  69. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/ibtracs.py +0 -0
  70. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/lcd.py +0 -0
  71. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/mrms.py +0 -0
  72. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/nbm.py +0 -0
  73. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/nexrad.py +0 -0
  74. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/nexrad_level3.py +0 -0
  75. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/normals.py +0 -0
  76. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/nws_vtec.py +0 -0
  77. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/rap.py +0 -0
  78. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/sites.py +0 -0
  79. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/spc.py +0 -0
  80. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/noaa/storm_events.py +0 -0
  81. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/params.py +0 -0
  82. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/usgs/__init__.py +0 -0
  83. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/usgs/daily.py +0 -0
  84. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/providers/usgs/earthquakes.py +0 -0
  85. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/py.typed +0 -0
  86. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/query.py +0 -0
  87. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/readers.py +0 -0
  88. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/registry.py +0 -0
  89. {usdata-0.31.0 → usdata-0.33.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.31.0
3
+ Version: 0.33.0
4
4
  Summary: Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data
5
5
  Keywords: noaa,usgs,open-data,scientific-data,provenance,reproducible-research,weather,climate,meteorology
6
6
  Author: Jake Van Slyke
@@ -113,7 +113,7 @@ breaking changes.
113
113
  | [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
114
114
  | [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
115
115
 
116
- Twenty-eight datasets are available today and eighteen more are planned, grouped
116
+ Thirty-one datasets are available today and seventeen more are planned, grouped
117
117
  by agency and product family in the [catalog](docs/providers/README.md).
118
118
 
119
119
  ## How this compares
@@ -68,7 +68,7 @@ breaking changes.
68
68
  | [docs.usdata.dev](https://docs.usdata.dev/) | How to use it: [install](https://docs.usdata.dev/install/), [getting started](https://docs.usdata.dev/getting-started/), guides, dataset notes, and reference |
69
69
  | [Severe-weather case study](https://usdata.dev/studies/severe-weather-case-study/) | One tornado, six sources, one manifest and lockfile, ending in a citation |
70
70
 
71
- Twenty-eight datasets are available today and eighteen more are planned, grouped
71
+ Thirty-one datasets are available today and seventeen more are planned, grouped
72
72
  by agency and product family in the [catalog](docs/providers/README.md).
73
73
 
74
74
  ## How this compares
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.31.0"
3
+ version = "0.33.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.31.0"
3
+ version = "0.33.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"
@@ -13,6 +13,7 @@ except PackageNotFoundError: # running from a source tree without an install
13
13
  from usdata._fetch import ChecksumMismatch, FetchedAsset, fetch, fetch_asset
14
14
  from usdata.cite import Citation, cite_dataset, cite_lockfile
15
15
  from usdata.inspect import inspect_asset, inspect_path
16
+ from usdata.messages import MessageListing, list_messages
16
17
  from usdata.models import (
17
18
  Asset,
18
19
  BBox,
@@ -23,7 +24,7 @@ from usdata.models import (
23
24
  TemporalSelection,
24
25
  TimeRange,
25
26
  )
26
- from usdata.pull import pull, verify
27
+ from usdata.pull import compare_listing, pull, verify
27
28
  from usdata.query import build_query
28
29
  from usdata.registry import DatasetNotFound, Registry, SearchResult, default_registry
29
30
  from usdata.selection import select_by_time
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "Dataset",
37
38
  "DatasetNotFound",
38
39
  "FetchedAsset",
40
+ "MessageListing",
39
41
  "Place",
40
42
  "Provenance",
41
43
  "Query",
@@ -47,6 +49,7 @@ __all__ = [
47
49
  "build_query",
48
50
  "cite_dataset",
49
51
  "cite_lockfile",
52
+ "compare_listing",
50
53
  "datasets",
51
54
  "default_registry",
52
55
  "fetch",
@@ -54,6 +57,7 @@ __all__ = [
54
57
  "get",
55
58
  "inspect_asset",
56
59
  "inspect_path",
60
+ "list_messages",
57
61
  "pull",
58
62
  "search",
59
63
  "select_by_time",
@@ -19,9 +19,10 @@ import gzip
19
19
  import re
20
20
  import warnings
21
21
  from collections.abc import Iterator, Mapping, Sequence
22
- from contextlib import contextmanager
22
+ from contextlib import contextmanager, suppress
23
23
  from datetime import UTC, datetime
24
24
  from importlib import import_module
25
+ from importlib.util import find_spec
25
26
  from pathlib import Path
26
27
  from typing import TYPE_CHECKING, Any, NamedTuple
27
28
 
@@ -86,9 +87,31 @@ PROJECTION_KEYS = (
86
87
  )
87
88
 
88
89
 
90
+ def import_eccodes() -> Any:
91
+ """Import the ecCodes bindings, loading pyproj first when it is installed.
92
+
93
+ The eckitlib wheel that the ecCodes library wheel depends on bundles its own
94
+ copy of the PROJ library, and so does pyproj's wheel, which xradar and many
95
+ analysis stacks bring in. When eckitlib's copy is loaded first and pyproj is
96
+ imported later in the same process, both copies free the same memory as the
97
+ interpreter exits, which aborts it with "double free or corruption" and exit
98
+ status 134 after the work is done. Loading pyproj's copy first avoids it.
99
+ A pyproj that fails to import is left for whoever uses it to report. This is
100
+ a workaround for https://github.com/ecmwf/eckit/issues/354; once an eckitlib
101
+ wheel no longer clashes with pyproj's PROJ, it can go.
102
+
103
+ Raises:
104
+ ModuleNotFoundError: eccodes is not installed.
105
+ """
106
+ if find_spec("pyproj") is not None:
107
+ with suppress(ImportError, OSError):
108
+ import_module("pyproj")
109
+ return import_module("eccodes")
110
+
111
+
89
112
  def _modules() -> tuple[Any, Any, Any]:
90
113
  try:
91
- eccodes = import_module("eccodes")
114
+ eccodes = import_eccodes()
92
115
  xarray = import_module("xarray")
93
116
  numpy = import_module("numpy")
94
117
  except ModuleNotFoundError as error:
@@ -460,8 +483,11 @@ def open_grib2(
460
483
  needs_select = select is None and not fetched.provenance.is_partial
461
484
  object_messages = fetched.provenance.object_messages
462
485
  selectors = fetched.provenance.selectors if object_messages else []
486
+ fields_seen: dict[int, int] = {}
463
487
  for index, h in _messages(eccodes, fetched.path):
464
488
  count += 1
489
+ # Fields of one message share its index; the nth of them is the index's field n+1.
490
+ field = fields_seen[index] = fields_seen.get(index, -1) + 1
465
491
  if count > 1 and needs_select:
466
492
  continue
467
493
  hits = {key: _matched(eccodes, h, key, wanted) for key, wanted in options.items()}
@@ -515,7 +541,7 @@ def open_grib2(
515
541
  "step": attrs.get("step"),
516
542
  }
517
543
  if selectors:
518
- message["selector"] = selectors[index]
544
+ message["selector"] = fetched.provenance.field_selector(index, field)
519
545
  short = base_name(
520
546
  message["shortName"], [attrs.get(key) for key in PARAMETER_KEYS], fetched.asset.id
521
547
  )
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from collections.abc import Iterable
7
7
  from pathlib import Path
8
- from typing import Annotated
8
+ from typing import Annotated, Any
9
9
 
10
10
  import httpx
11
11
  import typer
@@ -20,10 +20,19 @@ from usdata.cli.doctor import doctor
20
20
  from usdata.cli.inspect import inspect
21
21
  from usdata.cli.progress import progress
22
22
  from usdata.manifest import lockfile_path
23
+ from usdata.messages import list_messages
23
24
  from usdata.models import READER_EXTRAS_TEXT, Dataset, Status, describe_duration
24
25
  from usdata.providers import adapter_class, load_adapter
25
26
  from usdata.providers.base import NotImplementedProvider
26
- from usdata.pull import EmptySource, ManifestChanged, Plan, UnknownDatasets, UpstreamChanged
27
+ from usdata.pull import (
28
+ EmptySource,
29
+ ListingComparison,
30
+ ManifestChanged,
31
+ Plan,
32
+ UnknownDatasets,
33
+ UpstreamChanged,
34
+ )
35
+ from usdata.pull import compare_listing as compare_manifest_listing
27
36
  from usdata.pull import plan as plan_manifest
28
37
  from usdata.pull import pull as pull_manifest
29
38
  from usdata.pull import verify as verify_manifest
@@ -59,6 +68,33 @@ _QUERY_FLAGS = {
59
68
  }
60
69
 
61
70
 
71
+ def _parse_params(
72
+ param: list[str] | None, command: str, flags: dict[str, str | None]
73
+ ) -> dict[str, Any]:
74
+ """``--param key=value`` pairs as a dict of strings; exit 2 on a malformed or reserved key.
75
+
76
+ ``flags`` maps each reserved query option to the flag ``command`` takes it
77
+ through, or to ``None`` when ``command`` does not take it.
78
+ """
79
+ params: dict[str, Any] = {}
80
+ for item in param or []:
81
+ key, sep, value = item.partition("=")
82
+ if not sep:
83
+ typer.secho(f"--param expects key=value, got {item!r}", err=True, fg="red")
84
+ raise typer.Exit(code=2)
85
+ if key in flags:
86
+ flag = flags[key]
87
+ hint = (
88
+ f"use {flag} instead of --param"
89
+ if flag is not None
90
+ else f"{command} does not support this option"
91
+ )
92
+ typer.secho(f"{key} is a reserved query option; {hint}", err=True, fg="red")
93
+ raise typer.Exit(code=2)
94
+ params[key] = value
95
+ return params
96
+
97
+
62
98
  def _size_phrase(known_bytes: int, unknown: int, *, sources: Iterable[str] = ()) -> str:
63
99
  """How many bytes a dry run would move, said honestly when some size is missing.
64
100
 
@@ -349,26 +385,7 @@ def fetch(
349
385
  ] = False,
350
386
  ) -> None:
351
387
  """Resolve a query against one dataset and download the matching assets."""
352
- params: dict[str, str] = {}
353
- for item in param or []:
354
- key, sep, value = item.partition("=")
355
- if not sep:
356
- typer.secho(f"--param expects key=value, got {item!r}", err=True, fg="red")
357
- raise typer.Exit(code=2)
358
- if key in _QUERY_FLAGS:
359
- flag = _QUERY_FLAGS[key]
360
- hint = (
361
- f"use {flag} instead of --param"
362
- if flag is not None
363
- else "fetch does not support this option"
364
- )
365
- typer.secho(
366
- f"{key} is a reserved query option; {hint}",
367
- err=True,
368
- fg="red",
369
- )
370
- raise typer.Exit(code=2)
371
- params[key] = value
388
+ params = _parse_params(param, "fetch", _QUERY_FLAGS)
372
389
  box = None
373
390
  if bbox:
374
391
  try:
@@ -429,6 +446,64 @@ def fetch(
429
446
  typer.echo(f"{f.path}\t{tag}\t{f.provenance.size} bytes")
430
447
 
431
448
 
449
+ _MESSAGES_FLAGS: dict[str, str | None] = {
450
+ key: flag if key in ("start", "end") else None for key, flag in _QUERY_FLAGS.items()
451
+ }
452
+ """The query options ``messages`` takes: a model run is selected by its window alone."""
453
+
454
+
455
+ @app.command()
456
+ def messages(
457
+ dataset_id: Annotated[str, typer.Argument(help="Dataset id, e.g. noaa:hrrr")],
458
+ start: Annotated[str | None, typer.Option(help="ISO date or datetime.")] = None,
459
+ end: Annotated[
460
+ str | None,
461
+ typer.Option(help="ISO date or datetime; a date alone runs to the end of that UTC day."),
462
+ ] = None,
463
+ param: Annotated[
464
+ list[str] | None,
465
+ typer.Option("--param", "-p", help="Provider-specific key=value, repeatable."),
466
+ ] = None,
467
+ as_json: Annotated[
468
+ bool,
469
+ typer.Option("--json", help="Emit a JSON array of listings and nothing else on stdout."),
470
+ ] = False,
471
+ ) -> None:
472
+ """List the GRIB2 messages each file of a query holds, spelled as the messages param takes them.
473
+
474
+ One tab-separated line per field: asset id, message number, bytes, and the
475
+ selector that names exactly that field. Only the index sidecars are read.
476
+ """
477
+ params = _parse_params(param, "messages", _MESSAGES_FLAGS)
478
+ try:
479
+ ds = default_registry().get(dataset_id)
480
+ listings = list_messages(ds, build_query(start=start, end=end, **params))
481
+ except (DatasetNotFound, ValueError) as e:
482
+ typer.secho(str(e), err=True, fg="red")
483
+ raise typer.Exit(code=2) from None
484
+ except NotImplementedProvider as e:
485
+ typer.secho(str(e), err=True, fg="yellow")
486
+ raise typer.Exit(code=3) from None
487
+ except httpx.HTTPError as e:
488
+ typer.secho(f"request failed: {e}", err=True, fg="red")
489
+ raise typer.Exit(code=4) from None
490
+ if as_json:
491
+ typer.echo(json.dumps([listing.model_dump(mode="json") for listing in listings], indent=2))
492
+ else:
493
+ for listing in listings:
494
+ for entry in listing.messages:
495
+ number = entry.number if entry.field is None else f"{entry.number}.{entry.field}"
496
+ typer.echo(f"{listing.asset.id}\t{number}\t{entry.length}\t{entry.selector}")
497
+ count = sum(len(listing.messages) for listing in listings)
498
+ typer.echo(
499
+ f"{count} message(s) in {len(listings)} file(s); a selector may stop after its "
500
+ "level text, as CAPE:surface, to match every step",
501
+ err=True,
502
+ )
503
+ if not listings:
504
+ raise typer.Exit(code=1)
505
+
506
+
432
507
  def _print_plan(plan: Plan, *, as_json: bool, quiet: bool) -> None:
433
508
  """Report what a pull would fetch: assets, a subtotal per source, a total on stderr."""
434
509
  if as_json:
@@ -554,12 +629,35 @@ def pull(
554
629
  def verify(
555
630
  manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
556
631
  cache_dir: Annotated[Path | None, typer.Option(help="Override the cache directory.")] = None,
632
+ listing: Annotated[
633
+ bool,
634
+ typer.Option(
635
+ "--listing",
636
+ help="Instead of the cache, compare the lockfile with what each source lists today; "
637
+ "downloads nothing, but asks every source.",
638
+ ),
639
+ ] = False,
640
+ as_json: Annotated[
641
+ bool, typer.Option("--json", help="With --listing, emit the comparison as JSON on stdout.")
642
+ ] = False,
557
643
  ) -> None:
558
- """Check cached files against a manifest's lockfile. Exit 1 on any drift."""
644
+ """Check cached files, or with --listing today's source listings, against the lockfile.
645
+
646
+ Exit 1 on any drift.
647
+ """
648
+ if as_json and not listing:
649
+ typer.secho("--json applies to --listing only", err=True, fg="red")
650
+ raise typer.Exit(code=2)
651
+ if listing and cache_dir is not None:
652
+ typer.secho("--listing reads no cache; drop --cache-dir", err=True, fg="red")
653
+ raise typer.Exit(code=2)
559
654
  lock = lockfile_path(manifest)
560
655
  if not lock.exists():
561
656
  typer.secho(f"no lockfile at {lock}; run pull first", err=True, fg="red")
562
657
  raise typer.Exit(code=2)
658
+ if listing:
659
+ _verify_listing(manifest, as_json=as_json)
660
+ return
563
661
  try:
564
662
  drift = verify_manifest(manifest, root=cache_dir)
565
663
  except (ManifestChanged, ValueError, OSError) as e:
@@ -573,4 +671,52 @@ def verify(
573
671
  typer.echo(f"all assets match {lock.name}", err=True)
574
672
 
575
673
 
674
+ def _verify_listing(manifest: Path, *, as_json: bool) -> None:
675
+ """Compare the lockfile with today's listings; exit 1 when a re-resolve would differ."""
676
+ try:
677
+ comparison = compare_manifest_listing(manifest)
678
+ except (DatasetNotFound, UnknownDatasets, ManifestChanged, UnknownPlace, ValueError) as e:
679
+ typer.secho(str(e), err=True, fg="red")
680
+ raise typer.Exit(code=2) from None
681
+ except NotImplementedProvider as e:
682
+ typer.secho(str(e), err=True, fg="yellow")
683
+ raise typer.Exit(code=3) from None
684
+ except httpx.HTTPError as e:
685
+ typer.secho(f"listing failed: {e}", err=True, fg="red")
686
+ raise typer.Exit(code=4) from None
687
+ _print_listing(comparison, as_json=as_json)
688
+ if not comparison.matches:
689
+ raise typer.Exit(code=1)
690
+
691
+
692
+ def _print_listing(comparison: ListingComparison, *, as_json: bool) -> None:
693
+ """One line per difference, a summary on stderr; or the whole comparison as JSON."""
694
+ name = comparison.lockfile.name
695
+ if as_json:
696
+ typer.echo(comparison.model_dump_json(indent=2))
697
+ else:
698
+ for source in comparison.sources:
699
+ for asset in source.added:
700
+ typer.echo(f"{source.source}\tadded\t{asset.id}\t{asset.href}")
701
+ for asset in source.removed:
702
+ typer.echo(f"{source.source}\tremoved\t{asset.id}\t{asset.href}")
703
+ for change in source.changed:
704
+ sizes = f"{_size(change.locked_size)} -> {_size(change.listed_size)} bytes"
705
+ typer.echo(f"{source.source}\tchanged\t{change.asset_id}\t{sizes}")
706
+ differing = [source.source for source in comparison.sources if not source.matches]
707
+ if differing:
708
+ typer.secho(
709
+ f"{len(differing)} source(s) list something other than {name} pins: "
710
+ f"{', '.join(differing)}. Pass pull --force to re-resolve.",
711
+ err=True,
712
+ fg="red",
713
+ )
714
+ else:
715
+ typer.echo(f"every source lists what {name} pins", err=True)
716
+
717
+
718
+ def _size(value: int | None) -> str:
719
+ return "?" if value is None else str(value)
720
+
721
+
576
722
  app.command()(doctor)
@@ -732,6 +732,52 @@ datasets:
732
732
  - { name: "TAVG", units: "degrees Celsius", description: "Monthly mean temperature (metric units)" }
733
733
  adapter: usdata.providers.noaa.gsom:GlobalSummaryMonthly
734
734
 
735
+ - id: noaa:gsom-station-files
736
+ provider: noaa
737
+ status: available
738
+ domain: surface-weather
739
+ since: "0.33"
740
+ title: Global Summary of the Month Station Files
741
+ description: >-
742
+ The same Global Summary of the Month as noaa:gsom, as NCEI's static per-station files:
743
+ one CSV per station holding its whole monthly record, every element with its attributes,
744
+ in metric units. Each station is one plain HTTPS GET that does not go through the Access
745
+ Data Service, which suits pulling many stations' long records. There is no selection by
746
+ time, place, or element; name stations and select months and columns after opening.
747
+ Variables are NCEI element codes, the catalog lists the common ones, and the delivered
748
+ set is open and depends on the station.
749
+ keywords: [climate, monthly, stations, snowfall, temperature, precipitation, gsom, ncei]
750
+ protocol: http
751
+ homepage: https://www.ncei.noaa.gov/data/global-summary-of-the-month/
752
+ license: US Government Work (public domain)
753
+ spatial_extent: { west: -180.0, south: -90.0, east: 180.0, north: 90.0 }
754
+ temporal_extent: { start: "1763-01-01T00:00:00Z" }
755
+ capabilities: { spatial_subset: false, temporal_subset: false, variable_subset: false }
756
+ summary: Whole monthly station records, one file per station
757
+ formats: [CSV]
758
+ selection: One whole file per named station, every month and element it holds
759
+ inputs: Station IDs; no dates, geographic filters, or variables
760
+ reader: pandas
761
+ guide: docs/providers/noaa-gsom-station-files.md
762
+ examples:
763
+ - examples/datasets/noaa-gsom-station-files/noaa-gsom-station-files.ipynb
764
+ - examples/studies/el-nino-snowfall/el-nino-snowfall.ipynb
765
+ resolution:
766
+ spatial: "Land surface stations, derived from GHCN-Daily"
767
+ temporal: "Monthly"
768
+ update_frequency: "Weekly"
769
+ citation: >-
770
+ Lawrimore, Jay H.; Ray, Ron; Applequist, Scott; Korzeniewski, Bryant; Menne, Matthew J.
771
+ (2016): Global Summary of the Month (GSOM), Version 1. NOAA National Centers for
772
+ Environmental Information. https://doi.org/10.7289/V5QV3JJ5
773
+ terms: https://www.ncei.noaa.gov/metadata/geoportal/rest/metadata/item/gov.noaa.ncdc:C00946/html
774
+ variables:
775
+ - { name: "PRCP", units: "mm", description: "Monthly precipitation total" }
776
+ - { name: "SNOW", units: "mm", description: "Monthly snowfall total" }
777
+ - { name: "SNOW_ATTRIBUTES", description: "Days missing, measurement, quality, and source flags for SNOW" }
778
+ - { name: "TAVG", units: "degrees Celsius", description: "Monthly mean temperature" }
779
+ adapter: usdata.providers.noaa.gsom_files:GsomStationFiles
780
+
735
781
  - id: noaa:gsoy
736
782
  provider: noaa
737
783
  status: available
@@ -1125,19 +1171,93 @@ datasets:
1125
1171
 
1126
1172
  - id: noaa:ersst
1127
1173
  provider: noaa
1128
- status: planned
1174
+ status: available
1129
1175
  domain: ocean-physics
1130
- target: later
1131
- title: ERSST Monthly Sea Surface Temperature
1176
+ since: "0.33"
1177
+ title: ERSST v6 Monthly Sea Surface Temperature
1132
1178
  description: >-
1133
- Extended Reconstructed SST v5: monthly global 2 degree analysis since
1134
- 1854, one NetCDF per month in an NCEI HTTPS directory.
1135
- keywords: [sst, sea surface temperature, ocean, climate, ersst, monthly, netcdf]
1179
+ Extended Reconstructed Sea Surface Temperature version 6: NCEI's monthly global
1180
+ analysis on a 2 degree grid since January 1850, built from ship, buoy, and Argo
1181
+ observations. One NetCDF4 file per month in an anonymous NCEI HTTPS directory, holding
1182
+ sea surface temperature and its anomaly from the 1991-2020 ERSSTv6 climatology. Every
1183
+ month a window touches is selected whole; there is no server-side spatial or variable
1184
+ subsetting. Each monthly update writes the month just ended and rewrites the month
1185
+ before it, so the newest file is preliminary.
1186
+ keywords: [sst, sea surface temperature, ocean, climate, ersst, monthly, netcdf, enso, el nino]
1136
1187
  protocol: http
1137
1188
  homepage: https://www.ncei.noaa.gov/products/extended-reconstructed-sst
1138
1189
  license: US Government Work (public domain)
1139
- temporal_extent: { start: "1854-01-01T00:00:00Z" }
1190
+ spatial_extent: { west: -180.0, south: -89.0, east: 180.0, north: 89.0 }
1191
+ temporal_extent: { start: "1850-01-01T00:00:00Z" }
1140
1192
  capabilities: { spatial_subset: false, temporal_subset: true, variable_subset: false }
1193
+ summary: Monthly global 2 degree sea surface temperature since 1850
1194
+ formats: [NetCDF4]
1195
+ selection: Whole global monthly files, one for every month the window touches
1196
+ inputs: Both dates (selects the months they span)
1197
+ reader: netcdf
1198
+ guide: docs/providers/noaa-ersst.md
1199
+ examples:
1200
+ - examples/datasets/noaa-ersst/noaa-ersst.ipynb
1201
+ resolution:
1202
+ spatial: "2.0 degree grid"
1203
+ temporal: "Monthly means"
1204
+ update_frequency: >-
1205
+ Monthly, around the third: each update writes the month just ended and rewrites the
1206
+ month before it
1207
+ citation: >-
1208
+ Huang, B., X. Yin, T. Boyer, C. Liu, M. Menne, Y. D. Rao, T. Smith, R. Vose, and
1209
+ H.-M. Zhang, 2025: Extended Reconstructed Sea Surface Temperature Version 6 (ERSSTv6):
1210
+ Part I. An Artificial Neural Network Approach. Journal of Climate, 38, 1105-1121,
1211
+ doi:10.1175/JCLI-D-23-0707.1
1212
+ terms: https://www.ncei.noaa.gov/products/extended-reconstructed-sst
1213
+ variables:
1214
+ - { name: "sst", units: "degree_C", description: "Extended reconstructed sea surface temperature" }
1215
+ - { name: "ssta", units: "degree_C", description: "Anomaly from the 1991-2020 ERSSTv6 climatology" }
1216
+ adapter: usdata.providers.noaa.ersst:Ersst
1217
+
1218
+ - id: noaa:enso-indices
1219
+ provider: noaa
1220
+ status: available
1221
+ domain: climate
1222
+ since: "0.33"
1223
+ title: CPC ENSO Indices (RONI and ONI)
1224
+ description: >-
1225
+ The Climate Prediction Center's seasonal El Niño-Southern Oscillation indices: the
1226
+ Relative Oceanic Niño Index (RONI), which NOAA has used for official ENSO monitoring
1227
+ since 1 February 2026, and the traditional Oceanic Niño Index (ONI). Both are
1228
+ three-month running means of Niño 3.4 sea surface temperature anomalies from ERSST v6;
1229
+ RONI first subtracts the tropical mean anomaly. Each index is one whole plain-text table
1230
+ from DJF 1950, rewritten in place each month, with no server-side selection.
1231
+ keywords: [enso, el nino, la nina, oni, roni, nino 3.4, climate index, cpc, sea surface temperature]
1232
+ protocol: http
1233
+ homepage: https://www.cpc.ncep.noaa.gov/products/analysis_monitoring/enso/roni/
1234
+ license: US Government Work (public domain)
1235
+ spatial_extent: { west: -170.0, south: -5.0, east: -120.0, north: 5.0 }
1236
+ temporal_extent: { start: "1949-12-01T00:00:00Z" }
1237
+ capabilities: { spatial_subset: false, temporal_subset: false, variable_subset: false }
1238
+ summary: Seasonal El Niño indices, the official RONI and the traditional ONI
1239
+ formats: [whitespace-delimited text (no reader)]
1240
+ selection: One whole index table, every season from DJF 1950; select seasons locally
1241
+ inputs: Optional index (roni or oni); no dates or geographic filters
1242
+ reader: null
1243
+ guide: docs/providers/noaa-enso-indices.md
1244
+ examples:
1245
+ - examples/datasets/noaa-enso-indices/noaa-enso-indices.ipynb
1246
+ - examples/studies/el-nino-snowfall/el-nino-snowfall.ipynb
1247
+ resolution:
1248
+ spatial: "One value for the Niño 3.4 region, 5N-5S and 170W-120W"
1249
+ temporal: "Overlapping three-month seasons, one per month"
1250
+ update_frequency: "Monthly, by the 5th"
1251
+ citation: >-
1252
+ NOAA Climate Prediction Center, Relative Oceanic Niño Index and Oceanic Niño Index,
1253
+ accessed via usdata
1254
+ terms: https://www.weather.gov/disclaimer
1255
+ variables:
1256
+ - { name: "SEAS", description: "Three-month season, such as DJF" }
1257
+ - { name: "YR", description: "Year of the season's middle month" }
1258
+ - { name: "ANOM", units: "degree_C", description: "Index value: the season's anomaly" }
1259
+ - { name: "TOTAL", units: "degree_C", description: "Niño 3.4 mean temperature; ONI table only" }
1260
+ adapter: usdata.providers.noaa.enso:EnsoIndices
1141
1261
 
1142
1262
  - id: noaa:etopo
1143
1263
  provider: noaa
@@ -28,7 +28,7 @@ import httpx
28
28
  from pydantic import BaseModel
29
29
 
30
30
  from usdata import __version__
31
- from usdata._grib import LIBRARY_HINT
31
+ from usdata._grib import LIBRARY_HINT, import_eccodes
32
32
  from usdata.cache import ENV_VAR, cache_dir
33
33
  from usdata.mirror import ENV_VAR as MIRROR_ENV_VAR
34
34
  from usdata.models import READER_EXTRAS, Status
@@ -145,7 +145,8 @@ def _reader_check(extra: str) -> Check:
145
145
  modules: list[Any] = []
146
146
  for module in READER_MODULES[extra]:
147
147
  try:
148
- modules.append(import_module(module))
148
+ # ecCodes goes through the reader's import, which orders it after pyproj.
149
+ modules.append(import_eccodes() if module == "eccodes" else import_module(module))
149
150
  except (ImportError, RuntimeError, OSError) as error:
150
151
  # eccodes imports and then fails to load its shared library.
151
152
  return Check(
@@ -137,7 +137,8 @@ class Grib2Summary(BaseModel):
137
137
  KeyError: No message here was fetched for that selector; the message
138
138
  lists the selectors that were.
139
139
  ValueError: That selector fetched a message holding several fields,
140
- as RAP publishes wind components, and the record cannot say
140
+ as RAP publishes wind components, and the record was written
141
+ before each field's selector was recorded, so it cannot say
141
142
  which field it named; the message lists their variables.
142
143
  """
143
144
  from usdata._grib import variable_names
@@ -158,7 +159,7 @@ class Grib2Summary(BaseModel):
158
159
  if message.selector == selector
159
160
  ]
160
161
  if len(matched) > 1:
161
- # The record keeps one selector per byte range, and one range can hold several fields.
162
+ # An older record keeps one selector per range, and one range can hold several fields.
162
163
  raise ValueError(
163
164
  f"selector {selector!r} fetched one message holding {len(matched)} fields "
164
165
  f"({', '.join(matched)}); pick the variable by name"
@@ -333,24 +334,26 @@ def _paired(messages: list[GribMessage], record: Provenance) -> list[GribMessage
333
334
  """Each message as the fetch that took it described it, where provenance says.
334
335
 
335
336
  The file itself carries neither number nor selector: a partial fetch recorded
336
- one message number and one selector per range, and ``file_index`` counts the
337
- file's messages the same way, so the fields of a message that holds several
338
- all pair with its number. A whole file, or a record that does not pair,
339
- leaves both unset.
337
+ one message number per range and one selector per field, and ``file_index``
338
+ counts the file's messages the same way, so the fields of a message that
339
+ holds several all pair with its number and each with its own selector. A
340
+ whole file, or a record that does not pair, leaves both unset.
340
341
  """
341
342
  numbers = record.object_messages
342
343
  if not numbers or any(message.file_index >= len(numbers) for message in messages):
343
344
  return messages
344
- selectors = record.selectors if len(record.selectors) == len(numbers) else []
345
- return [
346
- message.model_copy(
347
- update={
348
- "object_index": numbers[message.file_index],
349
- "selector": selectors[message.file_index] if selectors else None,
350
- }
345
+ paired = len(record.selectors) == len(numbers)
346
+ fields_seen: dict[int, int] = {}
347
+ result = []
348
+ for message in messages:
349
+ field = fields_seen[message.file_index] = fields_seen.get(message.file_index, -1) + 1
350
+ selector = record.field_selector(message.file_index, field) if paired else None
351
+ result.append(
352
+ message.model_copy(
353
+ update={"object_index": numbers[message.file_index], "selector": selector}
354
+ )
351
355
  )
352
- for message in messages
353
- ]
356
+ return result
354
357
 
355
358
 
356
359
  def _csv_summary(path: Path, *, units_row: bool = False) -> CsvSummary: