usdata 0.12.0__tar.gz → 0.13.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 (50) hide show
  1. {usdata-0.12.0 → usdata-0.13.0}/PKG-INFO +1 -1
  2. {usdata-0.12.0 → usdata-0.13.0}/pyproject.toml +1 -1
  3. {usdata-0.12.0 → usdata-0.13.0}/pyproject.toml.orig +1 -1
  4. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/registry.yaml +3 -0
  5. usdata-0.13.0/src/usdata/protocols/listing.py +61 -0
  6. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/s3.py +0 -1
  7. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/base.py +55 -2
  8. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/coastwatch.py +7 -13
  9. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/coops.py +3 -10
  10. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/ghcnd.py +26 -16
  11. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/goes.py +15 -16
  12. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/gsom.py +1 -8
  13. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/gsoy.py +1 -11
  14. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/hurdat2.py +21 -33
  15. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/nexrad.py +10 -8
  16. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/normals.py +7 -19
  17. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/storm_events.py +13 -51
  18. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/usgs/daily.py +16 -18
  19. {usdata-0.12.0 → usdata-0.13.0}/README.md +0 -0
  20. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/__init__.py +0 -0
  21. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_files.py +0 -0
  22. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_hurdat2.py +0 -0
  23. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_netcdf.py +0 -0
  24. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_progress.py +0 -0
  25. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/_radar.py +0 -0
  26. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cache.py +0 -0
  27. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/__init__.py +0 -0
  28. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/app.py +0 -0
  29. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/cli/progress.py +0 -0
  30. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/nexrad_sites.csv +0 -0
  31. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/places.csv +0 -0
  32. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/data/places.sources.json +0 -0
  33. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/fetch.py +0 -0
  34. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/manifest.py +0 -0
  35. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/models.py +0 -0
  36. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/__init__.py +0 -0
  37. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/erddap.py +0 -0
  38. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/protocols/http.py +0 -0
  39. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/provenance.py +0 -0
  40. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/__init__.py +0 -0
  41. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/_http.py +0 -0
  42. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/__init__.py +0 -0
  43. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/noaa/sites.py +0 -0
  44. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/providers/usgs/__init__.py +0 -0
  45. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/pull.py +0 -0
  46. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/py.typed +0 -0
  47. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/query.py +0 -0
  48. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/readers.py +0 -0
  49. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/registry.py +0 -0
  50. {usdata-0.12.0 → usdata-0.13.0}/src/usdata/selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: usdata
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Unified Python SDK and CLI for discovering, fetching, and tracking provenance of U.S. public scientific data
5
5
  Keywords: noaa,usgs,nasa,open-data,scientific-data,provenance
6
6
  Author: Jake Van Slyke
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "usdata"
3
- version = "0.12.0"
3
+ version = "0.13.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.12.0"
3
+ version = "0.13.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"
@@ -743,6 +743,7 @@ catalog:
743
743
  reader_extra: radar
744
744
  examples:
745
745
  - examples/radar-reflectivity/example.ipynb
746
+ - examples/event-context/example.ipynb
746
747
  noaa:goes-abi:
747
748
  guide: docs/providers/noaa-goes.md
748
749
  summary: GOES CONUS imagery
@@ -753,6 +754,7 @@ catalog:
753
754
  reader_extra: netcdf
754
755
  examples:
755
756
  - examples/goes-imagery/example.ipynb
757
+ - examples/event-context/example.ipynb
756
758
  noaa:storm-events:
757
759
  guide: docs/providers/noaa-storm-events.md
758
760
  summary: Storm Events details
@@ -763,6 +765,7 @@ catalog:
763
765
  reader_extra: pandas
764
766
  examples:
765
767
  - examples/storm-events/example.ipynb
768
+ - examples/event-context/example.ipynb
766
769
  noaa:hurdat2:
767
770
  guide: docs/providers/noaa-hurdat2.md
768
771
  summary: Tropical cyclone best tracks
@@ -0,0 +1,61 @@
1
+ """Read file names and byte sizes from Apache-style HTML directory indexes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from html.parser import HTMLParser
7
+
8
+
9
+ class _Index(HTMLParser):
10
+ """Collect (name, size) for anchors whose href is a literal local file name.
11
+
12
+ Only hrefs matching ``pattern`` in full are kept, so a listing can never
13
+ steer a download to an arbitrary link. The size is the first all-digit
14
+ table cell after the anchor's cell in the same row, or None.
15
+ """
16
+
17
+ def __init__(self, pattern: re.Pattern[str]) -> None:
18
+ super().__init__()
19
+ self.pattern = pattern
20
+ self.entries: list[tuple[str, int | None]] = []
21
+ self._in_row = False
22
+ self._in_cell = False
23
+ self._cells: list[str] = []
24
+ self._name: str | None = None
25
+ self._name_cell: int | None = None
26
+
27
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
28
+ if tag == "tr":
29
+ self._in_row, self._cells, self._name, self._name_cell = True, [], None, None
30
+ elif tag == "td":
31
+ self._cells.append("")
32
+ self._in_cell = True
33
+ elif tag == "a":
34
+ href = dict(attrs).get("href", "") or ""
35
+ if not self.pattern.fullmatch(href):
36
+ return
37
+ if self._in_row:
38
+ self._name, self._name_cell = href, len(self._cells) - 1
39
+ else:
40
+ self.entries.append((href, None))
41
+
42
+ def handle_data(self, data: str) -> None:
43
+ if self._in_cell:
44
+ self._cells[-1] += data
45
+
46
+ def handle_endtag(self, tag: str) -> None:
47
+ if tag == "td":
48
+ self._in_cell = False
49
+ elif tag == "tr":
50
+ if self._name is not None:
51
+ later = self._cells[(self._name_cell or 0) + 1 :]
52
+ sizes = [c.strip() for c in later if c.strip().isascii() and c.strip().isdigit()]
53
+ self.entries.append((self._name, int(sizes[0]) if sizes else None))
54
+ self._in_row = False
55
+
56
+
57
+ def directory_entries(html: str, pattern: re.Pattern[str]) -> list[tuple[str, int | None]]:
58
+ """File names matching ``pattern`` in a directory index, with byte sizes when listed."""
59
+ index = _Index(pattern)
60
+ index.feed(html)
61
+ return index.entries
@@ -61,7 +61,6 @@ def list_objects(
61
61
  try:
62
62
  while True:
63
63
  resp = http.get(https_url(bucket), client, params=params)
64
- resp.raise_for_status()
65
64
  root = ET.fromstring(resp.text)
66
65
  truncated = _text(root, "IsTruncated") == "true"
67
66
  token = _text(root, "NextContinuationToken")
@@ -5,12 +5,21 @@ from __future__ import annotations
5
5
  import importlib
6
6
  from abc import ABC, abstractmethod
7
7
  from collections.abc import Mapping
8
+ from datetime import UTC, datetime
8
9
  from pathlib import Path
9
10
  from types import MappingProxyType, TracebackType
10
- from typing import ClassVar, Self
11
+ from typing import ClassVar, Literal, Self
11
12
 
12
13
  from usdata.models import Asset, Dataset, Query
13
14
 
15
+ QueryField = Literal["text", "bbox", "variables", "time"]
16
+ _LABELS: dict[QueryField, str] = {
17
+ "text": "text",
18
+ "bbox": "location/bbox",
19
+ "variables": "variables",
20
+ "time": "start/end",
21
+ }
22
+
14
23
 
15
24
  class NotImplementedProvider(NotImplementedError):
16
25
  """Raised by adapters that are registered but not yet built."""
@@ -20,8 +29,30 @@ class QueryError(ValueError):
20
29
  """The query cannot be satisfied by this dataset (missing or unsupported constraints)."""
21
30
 
22
31
 
32
+ def to_utc(value: datetime) -> datetime:
33
+ """Apply the shared time policy: naive datetimes mean UTC, aware ones convert to it."""
34
+ return value.replace(tzinfo=value.tzinfo or UTC).astimezone(UTC)
35
+
36
+
37
+ def _is_set(query: Query, field: QueryField) -> bool:
38
+ if field == "text":
39
+ return bool(query.text)
40
+ if field == "bbox":
41
+ return query.bbox is not None
42
+ if field == "variables":
43
+ return bool(query.variables)
44
+ return query.time is not None
45
+
46
+
23
47
  class Provider(ABC):
24
- """One adapter per dataset. Translates a normalized query into concrete assets."""
48
+ """One adapter per dataset. Translates a normalized query into concrete assets.
49
+
50
+ ``list_assets`` implementations validate with the shared helpers before any
51
+ transport: ``check_params`` for provider-specific keys, ``reject`` for query
52
+ fields the source cannot honour, and ``utc_window`` for the time bounds.
53
+ Every adapter then reports the same errors for the same mistakes, and no
54
+ query field is silently ignored.
55
+ """
25
56
 
26
57
  accepted_params: ClassVar[Mapping[str, str]] = MappingProxyType({})
27
58
  """Accepted ``query.params`` keys, each mapped to a one-line description.
@@ -49,6 +80,28 @@ class Provider(ABC):
49
80
  """Release owned resources. Providers with resources override this method."""
50
81
  return None
51
82
 
83
+ def check_params(self, query: Query) -> None:
84
+ """Reject every ``query.params`` key outside ``accepted_params``, naming it."""
85
+ if unknown := set(query.params) - set(self.accepted_params):
86
+ raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
87
+
88
+ def reject(self, query: Query, *fields: QueryField, hint: str = "") -> None:
89
+ """Raise ``QueryError`` when the query sets a field this dataset cannot honour.
90
+
91
+ Sources that ignore a filter must say so rather than return unfiltered
92
+ data; ``hint`` tells the caller what to do instead.
93
+ """
94
+ present = [_LABELS[field] for field in fields if _is_set(query, field)]
95
+ if present:
96
+ message = f"{self.dataset.id} does not support {', '.join(present)}"
97
+ raise QueryError(f"{message}; {hint}" if hint else message)
98
+
99
+ def utc_window(self, query: Query) -> tuple[datetime, datetime]:
100
+ """Both time bounds, required, in UTC. Naive bounds are read as UTC."""
101
+ if query.time is None or query.time.start is None or query.time.end is None:
102
+ raise QueryError(f"{self.dataset.id} requires both start and end")
103
+ return to_utc(query.time.start), to_utc(query.time.end)
104
+
52
105
  @abstractmethod
53
106
  def list_assets(self, query: Query) -> list[Asset]:
54
107
  """Resolve a query to the concrete objects that satisfy it, without downloading."""
@@ -53,17 +53,11 @@ class CoastwatchSst(_HttpProvider):
53
53
 
54
54
  def list_assets(self, query: Query) -> list[Asset]:
55
55
  """Resolve a valid grid intersection into one stable, bounded CSV request."""
56
- if (
57
- query.bbox is None
58
- or query.time is None
59
- or query.time.start is None
60
- or query.time.end is None
61
- ):
62
- raise QueryError(
63
- f"{self.dataset.id} requires a bbox/location and both start and end times"
64
- )
65
- if unknown := set(query.params) - set(self.accepted_params):
66
- raise QueryError(f"unsupported CoastWatch params: {', '.join(sorted(unknown))}")
56
+ self.check_params(query)
57
+ self.reject(query, "text", hint="select a bbox/location, time window, and variables")
58
+ if query.bbox is None:
59
+ raise QueryError(f"{self.dataset.id} requires a bbox/location and both start and end")
60
+ start, end = self.utc_window(query)
67
61
  stride = query.params.get("stride", 1)
68
62
  if isinstance(stride, str) and stride.isascii() and stride.isdigit():
69
63
  stride = int(stride)
@@ -102,8 +96,8 @@ class CoastwatchSst(_HttpProvider):
102
96
  raise ValueError("unexpected time axis")
103
97
  except (KeyError, TypeError, ValueError) as error:
104
98
  raise httpx.DecodingError("CoastWatch grid metadata changed or is invalid") from error
105
- first = bisect_left(times, query.time.start)
106
- last = bisect_right(times, query.time.end) - 1
99
+ first = bisect_left(times, start)
100
+ last = bisect_right(times, end) - 1
107
101
  if first > last:
108
102
  return []
109
103
  if (last - first + 1) * lat[1] * lon[1] > MAX_ROWS:
@@ -89,12 +89,8 @@ class CoopsWaterLevels(_HttpProvider):
89
89
 
90
90
  def list_assets(self, query: Query) -> list[Asset]:
91
91
  """Describe one bounded CSV request; availability is checked during fetching."""
92
- if unknown := set(query.params) - set(self.accepted_params):
93
- raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
94
- if query.bbox is not None or query.text is not None or query.variables:
95
- raise QueryError(
96
- "CO-OPS requires an explicit station; location, text and variables are unsupported"
97
- )
92
+ self.check_params(query)
93
+ self.reject(query, "bbox", "text", "variables", hint="CO-OPS requires an explicit station")
98
94
  station = query.params.get("station")
99
95
  if (
100
96
  not isinstance(station, str)
@@ -109,10 +105,7 @@ class CoopsWaterLevels(_HttpProvider):
109
105
  units = query.params.get("units", "metric")
110
106
  if units not in ("metric", "english"):
111
107
  raise QueryError("units must be metric or english")
112
- if query.time is None or query.time.start is None or query.time.end is None:
113
- raise QueryError(f"{self.dataset.id} requires both start and end timestamps")
114
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
115
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
108
+ start, end = self.utc_window(query)
116
109
  if any(value.second or value.microsecond for value in (start, end)):
117
110
  raise QueryError("CO-OPS timestamps must have minute precision (zero seconds)")
118
111
  if end - start > MAX_INTERVAL:
@@ -79,7 +79,6 @@ class GhcnDaily(_HttpProvider):
79
79
  seen: set[str] = set()
80
80
  while True:
81
81
  resp = http.get(SEARCH_URL, self._http(), params=params)
82
- resp.raise_for_status()
83
82
  body = resp.json()
84
83
  results = body.get("results", [])
85
84
  before = len(found)
@@ -111,24 +110,35 @@ class GhcnDaily(_HttpProvider):
111
110
  break
112
111
  return found
113
112
 
114
- def list_assets(self, query: Query) -> list[Asset]:
115
- """One CSV asset per chunk of up to STATIONS_PER_ASSET stations for the query window."""
116
- if query.time is None or query.time.start is None or query.time.end is None:
117
- raise QueryError(f"{self.dataset.id} requires both start and end dates")
118
- if unknown := set(query.params) - set(self.accepted_params):
119
- raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
120
- if query.params.get("units", "metric") not in ("metric", "standard"):
113
+ def _units(self, query: Query) -> str:
114
+ units = query.params.get("units", "metric")
115
+ if units not in ("metric", "standard"):
121
116
  raise QueryError("units must be metric or standard")
117
+ return units
118
+
119
+ def _stations(self, query: Query) -> list[str]:
120
+ """Explicit ``stations`` or the ones found inside the bbox, never a mix of both."""
121
+ if "stations" in query.params and query.bbox is not None:
122
+ raise QueryError("pass stations or a location/bbox, not both")
122
123
  if "stations" in query.params:
123
- stations = _stations_param(query.params["stations"])
124
- elif query.bbox is not None:
125
- stations = self.find_stations(query)
126
- else:
127
- raise QueryError(f"{self.dataset.id} needs a location, bbox, or stations=...")
124
+ return _stations_param(query.params["stations"])
125
+ if query.bbox is not None:
126
+ return self.find_stations(query)
127
+ raise QueryError(f"{self.dataset.id} needs a location, bbox, or stations=...")
128
+
129
+ def list_assets(self, query: Query) -> list[Asset]:
130
+ """One CSV asset per chunk of up to STATIONS_PER_ASSET stations for the query window."""
131
+ self.check_params(query)
132
+ self.reject(query, "text", hint="search the registry instead")
133
+ start_at, end_at = self.utc_window(query)
134
+ window = TimeRange(start=start_at, end=end_at)
135
+ query = query.model_copy(update={"time": window})
136
+ units = self._units(query)
137
+ stations = self._stations(query)
128
138
  if not stations:
129
139
  return []
130
140
 
131
- start, end = _date(query.time.start), _date(query.time.end)
141
+ start, end = _date(window.start), _date(window.end)
132
142
  assets: list[Asset] = []
133
143
  for i in range(0, len(stations), STATIONS_PER_ASSET):
134
144
  chunk = stations[i : i + STATIONS_PER_ASSET]
@@ -138,7 +148,7 @@ class GhcnDaily(_HttpProvider):
138
148
  "startDate": start,
139
149
  "endDate": end,
140
150
  "format": "csv",
141
- "units": query.params.get("units", "metric"),
151
+ "units": units,
142
152
  "includeStationLocation": "1",
143
153
  }
144
154
  if query.variables:
@@ -152,7 +162,7 @@ class GhcnDaily(_HttpProvider):
152
162
  href=url,
153
163
  protocol=Protocol.HTTP,
154
164
  media_type="text/csv",
155
- time=TimeRange(start=query.time.start, end=query.time.end),
165
+ time=window,
156
166
  bbox=query.bbox,
157
167
  )
158
168
  )
@@ -1,8 +1,9 @@
1
1
  """GOES ABI CONUS Cloud and Moisture Imagery from anonymous NOAA S3 buckets.
2
2
 
3
3
  Require ``satellite`` (16, 17, 18, or 19), ``channel`` (1--16 or C01--C16), and
4
- both timestamps. The optional ``product`` must be ``ABI-L2-CMIPC``. Select whole
5
- single-channel NetCDF files by inclusive scan-start time, never by scan overlap.
4
+ both timestamps at most seven days apart. The optional ``product`` must be
5
+ ``ABI-L2-CMIPC``. Select whole single-channel NetCDF files by inclusive
6
+ scan-start time, never by scan overlap.
6
7
  Geographic and variable subsetting are not available for these archived files.
7
8
  """
8
9
 
@@ -21,6 +22,7 @@ from usdata.providers.base import QueryError
21
22
 
22
23
  PRODUCT = "ABI-L2-CMIPC"
23
24
  PUBLIC_START = datetime(2017, 2, 28, tzinfo=UTC)
25
+ MAX_WINDOW = timedelta(days=7)
24
26
  KEY_RE = re.compile(
25
27
  r"OR_ABI-L2-CMIPC-M[346]C(?P<channel>0[1-9]|1[0-6])_G(?P<satellite>1[6-9])"
26
28
  r"_s(?P<start>\d{14})_e(?P<end>\d{14})_c\d{14}\.nc"
@@ -57,24 +59,21 @@ class GoesAbi(_HttpProvider):
57
59
 
58
60
  def list_assets(self, query: Query) -> list[Asset]:
59
61
  """List complete scenes whose scan starts fall inside the inclusive UTC interval."""
60
- if unknown := set(query.params) - set(self.accepted_params):
61
- raise QueryError(f"unsupported GOES params: {', '.join(sorted(unknown))}")
62
+ self.check_params(query)
62
63
  if query.params.get("product", PRODUCT) != PRODUCT:
63
64
  raise QueryError(f"only product={PRODUCT} is supported")
64
- if query.bbox is not None:
65
- raise QueryError(
66
- "GOES imagery has no geographic subsetting; omit location/bbox/lat/lon"
67
- )
68
- if query.text:
69
- raise QueryError("GOES imagery has no text filtering; select satellite and channel")
70
- if query.variables:
71
- raise QueryError("GOES imagery has no variable subsetting; select a channel instead")
72
- if query.time is None or query.time.start is None or query.time.end is None:
73
- raise QueryError(f"{self.dataset.id} requires both start and end times")
65
+ self.reject(
66
+ query,
67
+ "bbox",
68
+ "text",
69
+ "variables",
70
+ hint="archived GOES scenes are whole files; select satellite and channel",
71
+ )
72
+ start, end = self.utc_window(query)
73
+ if end - start > MAX_WINDOW:
74
+ raise QueryError("GOES requests must span at most 7 days; split longer intervals")
74
75
  satellite = _number(query.params.get("satellite"), "satellite", 16, 19)
75
76
  channel = _number(query.params.get("channel"), "channel", 1, 16)
76
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
77
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
78
77
  if end < PUBLIC_START:
79
78
  raise QueryError("GOES CMIPC public observations begin on 2017-02-28")
80
79
  start = max(start, PUBLIC_START)
@@ -6,10 +6,8 @@ selected in full. Geographic discovery uses the same normalized month bounds.
6
6
  """
7
7
 
8
8
  from calendar import monthrange
9
- from datetime import UTC
10
9
 
11
10
  from usdata.models import Asset, Query, TimeRange
12
- from usdata.providers.base import QueryError
13
11
  from usdata.providers.noaa.ghcnd import GhcnDaily
14
12
 
15
13
 
@@ -19,10 +17,7 @@ class GlobalSummaryMonthly(GhcnDaily):
19
17
  ncei_dataset = "global-summary-of-the-month"
20
18
 
21
19
  def _monthly_query(self, query: Query) -> Query:
22
- if query.time is None or query.time.start is None or query.time.end is None:
23
- raise QueryError(f"{self.dataset.id} requires both start and end dates")
24
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
25
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
20
+ start, end = self.utc_window(query)
26
21
  return query.model_copy(
27
22
  update={
28
23
  "time": TimeRange(
@@ -44,6 +39,4 @@ class GlobalSummaryMonthly(GhcnDaily):
44
39
 
45
40
  def list_assets(self, query: Query) -> list[Asset]:
46
41
  """Resolve monthly CSVs with stable URLs and complete-month asset bounds."""
47
- if "stations" in query.params and query.bbox is not None:
48
- raise QueryError("pass stations or a location/bbox, not both")
49
42
  return super().list_assets(self._monthly_query(query))
@@ -5,10 +5,7 @@ Both dates are required; every UTC calendar year touched by the interval is
5
5
  selected in full. Geographic discovery uses the same normalized year bounds.
6
6
  """
7
7
 
8
- from datetime import UTC
9
-
10
8
  from usdata.models import Asset, Query, TimeRange
11
- from usdata.providers.base import QueryError
12
9
  from usdata.providers.noaa.ghcnd import GhcnDaily
13
10
 
14
11
 
@@ -18,14 +15,7 @@ class GlobalSummaryYearly(GhcnDaily):
18
15
  ncei_dataset = "global-summary-of-the-year"
19
16
 
20
17
  def _yearly_query(self, query: Query) -> Query:
21
- if query.text is not None:
22
- raise QueryError(f"{self.dataset.id} does not support text queries")
23
- if "stations" in query.params and query.bbox is not None:
24
- raise QueryError("pass stations or a location/bbox, not both")
25
- if query.time is None or query.time.start is None or query.time.end is None:
26
- raise QueryError(f"{self.dataset.id} requires both start and end dates")
27
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
28
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
18
+ start, end = self.utc_window(query)
29
19
  return query.model_copy(
30
20
  update={
31
21
  "time": TimeRange(
@@ -17,12 +17,12 @@ from __future__ import annotations
17
17
  import re
18
18
  from collections.abc import Mapping
19
19
  from datetime import UTC, date, datetime
20
- from html.parser import HTMLParser
21
20
  from pathlib import Path
22
21
  from typing import ClassVar
23
22
 
24
23
  from usdata.models import Asset, Protocol, Query, TimeRange
25
24
  from usdata.protocols import http
25
+ from usdata.protocols.listing import directory_entries
26
26
  from usdata.providers._http import _HttpProvider
27
27
  from usdata.providers.base import QueryError
28
28
 
@@ -33,20 +33,6 @@ FILE_NAME = re.compile(r"hurdat2-(?:(atl|nepac)-)?(\d{4})-(\d{4})-(\d{6}|\d{8})(
33
33
  BASIN_TOKENS = {"atlantic": frozenset({"", "atl"}), "pacific": frozenset({"nepac"})}
34
34
 
35
35
 
36
- class _Directory(HTMLParser):
37
- """Collect HURDAT2 filenames from the NHC Apache directory listing."""
38
-
39
- def __init__(self) -> None:
40
- super().__init__()
41
- self.names: list[str] = []
42
-
43
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
44
- href = dict(attrs).get("href", "") or "" if tag == "a" else ""
45
- # Only literal local filenames; never follow arbitrary links from a listing.
46
- if FILE_NAME.fullmatch(href):
47
- self.names.append(href)
48
-
49
-
50
36
  def _revision(value: str) -> date | None:
51
37
  """The revision date encoded in a filename, or None if it is not a real date."""
52
38
  try:
@@ -64,39 +50,40 @@ class Hurdat2(_HttpProvider):
64
50
 
65
51
  def list_assets(self, query: Query) -> list[Asset]:
66
52
  """Select the newest revision of the requested basin's complete database."""
67
- if unknown := set(query.params) - set(self.accepted_params):
68
- raise QueryError(f"unsupported HURDAT2 params: {', '.join(sorted(unknown))}")
53
+ self.check_params(query)
69
54
  requested = query.params.get("basin", "atlantic")
70
55
  basin = requested.strip().casefold() if isinstance(requested, str) else ""
71
56
  if basin not in BASIN_TOKENS:
72
57
  raise QueryError(
73
58
  f"unsupported HURDAT2 basin {requested!r}; use 'atlantic' (default) or 'pacific'"
74
59
  )
75
- if query.bbox is not None or query.variables or query.text:
76
- raise QueryError(
77
- "HURDAT2 publishes one whole text file per basin; location/bbox, variables, "
78
- "and text filters are unsupported. Filter the parsed track table locally."
79
- )
80
- if query.time is not None:
81
- raise QueryError(
82
- "HURDAT2 does not accept dates: every revision holds the complete basin "
83
- "record, so a date range would not change the download. Remove start/end "
84
- "and filter the parsed 'time' column locally."
85
- )
86
- listing = _Directory()
87
- listing.feed(http.get(DIRECTORY_URL, self._http()).text)
60
+ self.reject(
61
+ query,
62
+ "bbox",
63
+ "variables",
64
+ "text",
65
+ hint="HURDAT2 publishes one whole text file per basin; filter the parsed track "
66
+ "table locally",
67
+ )
68
+ self.reject(
69
+ query,
70
+ "time",
71
+ hint="every revision holds the complete basin record, so a date range would not "
72
+ "change the download; remove start/end and filter the parsed 'time' column locally",
73
+ )
74
+ page = http.get(DIRECTORY_URL, self._http()).text
88
75
  candidates = []
89
- for name in listing.names:
76
+ for name, size in directory_entries(page, FILE_NAME):
90
77
  match = FILE_NAME.fullmatch(name)
91
78
  assert match is not None
92
79
  first, last, revised = int(match[2]), int(match[3]), _revision(match[4])
93
80
  if (match[1] or "") not in BASIN_TOKENS[basin] or revised is None or first > last:
94
81
  continue
95
82
  # Prefer the latest season covered, then the newest revision of that span.
96
- candidates.append(((last, revised, name), first, last))
83
+ candidates.append(((last, revised, name), first, last, size))
97
84
  if not candidates:
98
85
  raise QueryError(f"no HURDAT2 {basin} best-track file in the NHC directory listing")
99
- (_, _, name), first, last = max(candidates)
86
+ (_, _, name), first, last, size = max(candidates)
100
87
  return [
101
88
  Asset(
102
89
  id=name,
@@ -104,6 +91,7 @@ class Hurdat2(_HttpProvider):
104
91
  href=DIRECTORY_URL + name,
105
92
  protocol=Protocol.HTTP,
106
93
  media_type="text/plain",
94
+ size=size,
107
95
  time=TimeRange(
108
96
  start=datetime(first, 1, 1, tzinfo=UTC),
109
97
  end=datetime(last, 12, 31, 23, 59, 59, 999999, tzinfo=UTC),
@@ -2,8 +2,8 @@
2
2
 
3
3
  Key layout: ``YYYY/MM/DD/SITE/SITEYYYYMMDD_HHMMSS[_V06][.gz]``. One object per
4
4
  volume scan. ``_MDM`` objects are metadata sidecars and are skipped. There is
5
- no server-side subsetting; a query selects sites and a time window and every
6
- whole scan in that window is an asset.
5
+ no server-side subsetting; a query selects sites and a time window of at most
6
+ 31 days, and every whole scan in that window is an asset.
7
7
 
8
8
  Site selection, in order: ``site=``/``sites=`` params, then radars located
9
9
  inside the query bbox, then the single radar nearest the bbox centre.
@@ -25,6 +25,7 @@ from usdata.providers.base import QueryError
25
25
  from usdata.providers.noaa import sites
26
26
 
27
27
  BUCKET = "unidata-nexrad-level2"
28
+ MAX_WINDOW = timedelta(days=31)
28
29
  KEY_RE = re.compile(r"^(?P<site>[A-Z]{4})(?P<stamp>\d{8}_\d{6})(?:_V0[36])?(?:\.gz)?$")
29
30
 
30
31
 
@@ -58,8 +59,7 @@ class NexradLevel2(_HttpProvider):
58
59
 
59
60
  def select_sites(self, query: Query) -> list[str]:
60
61
  """Radar site ids the query refers to; see the module docstring for the rules."""
61
- if unknown := set(query.params) - set(self.accepted_params):
62
- raise QueryError(f"unsupported NEXRAD params: {', '.join(sorted(unknown))}")
62
+ self.check_params(query)
63
63
  if "site" in query.params and "sites" in query.params:
64
64
  raise QueryError("pass only one of site or sites")
65
65
  if "nearest" in query.params and {"site", "sites"}.intersection(query.params):
@@ -95,10 +95,12 @@ class NexradLevel2(_HttpProvider):
95
95
 
96
96
  def list_assets(self, query: Query) -> list[Asset]:
97
97
  """Every volume scan for the selected sites inside the query's UTC time window."""
98
- if query.time is None or query.time.start is None or query.time.end is None:
99
- raise QueryError(f"{self.dataset.id} requires both start and end times")
100
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
101
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
98
+ self.reject(
99
+ query, "text", "variables", hint="whole volume scans are selected by site and time"
100
+ )
101
+ start, end = self.utc_window(query)
102
+ if end - start > MAX_WINDOW:
103
+ raise QueryError("NEXRAD requests must span at most 31 days; split longer intervals")
102
104
  assets: list[Asset] = []
103
105
  for site in self.select_sites(query):
104
106
  day = start.date()
@@ -21,8 +21,8 @@ from typing import Any, ClassVar
21
21
  import httpx
22
22
 
23
23
  from usdata.models import Asset, Protocol, Query, TimeRange
24
- from usdata.providers.base import QueryError
25
- from usdata.providers.noaa.ghcnd import DATA_URL, STATIONS_PER_ASSET, GhcnDaily, _stations_param
24
+ from usdata.providers.base import QueryError, to_utc
25
+ from usdata.providers.noaa.ghcnd import DATA_URL, STATIONS_PER_ASSET, GhcnDaily
26
26
 
27
27
  PERIODS = {
28
28
  "monthly": "normals-monthly-1991-2020",
@@ -55,8 +55,7 @@ def _window(query: Query, period: str) -> tuple[str, str] | None:
55
55
  return (f"{PLACEHOLDER_YEAR}-01-01", f"{PLACEHOLDER_YEAR}-12-31")
56
56
  if start is None or end is None:
57
57
  raise QueryError("pass both start and end dates, or neither for the whole year")
58
- first = start.replace(tzinfo=start.tzinfo or UTC).astimezone(UTC)
59
- last = end.replace(tzinfo=end.tzinfo or UTC).astimezone(UTC)
58
+ first, last = to_utc(start), to_utc(end)
60
59
  if (first.month, first.day) > (last.month, last.day):
61
60
  raise QueryError("normals windows cannot cross the new year; split the query")
62
61
  return (
@@ -80,23 +79,12 @@ class ClimateNormals(GhcnDaily):
80
79
 
81
80
  def list_assets(self, query: Query) -> list[Asset]:
82
81
  """One CSV asset per chunk of stations for the selected period and window."""
83
- if query.text is not None:
84
- raise QueryError(f"{self.dataset.id} does not support text queries")
85
- if unknown := set(query.params) - set(self.accepted_params):
86
- raise QueryError(f"unsupported {self.dataset.id} params: {', '.join(sorted(unknown))}")
82
+ self.check_params(query)
83
+ self.reject(query, "text", hint="search the registry instead")
87
84
  period = _period(query)
88
- units = query.params.get("units", "metric")
89
- if units not in ("metric", "standard"):
90
- raise QueryError("units must be metric or standard")
91
- if "stations" in query.params and query.bbox is not None:
92
- raise QueryError("pass stations or a location/bbox, not both")
85
+ units = self._units(query)
93
86
  window = _window(query, period)
94
- if "stations" in query.params:
95
- stations = _stations_param(query.params["stations"])
96
- elif query.bbox is not None:
97
- stations = self.find_stations(query)
98
- else:
99
- raise QueryError(f"{self.dataset.id} needs a location, bbox, or stations=...")
87
+ stations = self._stations(query)
100
88
  if not stations:
101
89
  return []
102
90
 
@@ -9,11 +9,11 @@ from __future__ import annotations
9
9
 
10
10
  import re
11
11
  from datetime import UTC, datetime
12
- from html.parser import HTMLParser
13
12
  from pathlib import Path
14
13
 
15
14
  from usdata.models import Asset, Protocol, Query, TimeRange
16
15
  from usdata.protocols import http
16
+ from usdata.protocols.listing import directory_entries
17
17
  from usdata.providers._http import _HttpProvider
18
18
  from usdata.providers.base import QueryError
19
19
 
@@ -21,65 +21,27 @@ DIRECTORY_URL = "https://www.ncei.noaa.gov/pub/data/swdi/stormevents/csvfiles/"
21
21
  DETAILS_NAME = re.compile(r"StormEvents_details-ftp_v1\.0_d(\d{4})_c(\d{8})\.csv\.gz", re.ASCII)
22
22
 
23
23
 
24
- class _Directory(HTMLParser):
25
- """Read filenames and exact byte sizes from NCEI's HTML directory table."""
26
-
27
- def __init__(self) -> None:
28
- super().__init__()
29
- self.files: list[tuple[str, int | None]] = []
30
- self._cells: list[str] = []
31
- self._name: str | None = None
32
- self._in_cell = False
33
-
34
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
35
- if tag == "tr":
36
- self._cells, self._name = [], None
37
- elif tag == "td":
38
- self._cells.append("")
39
- self._in_cell = True
40
- elif tag == "a":
41
- href = dict(attrs).get("href", "") or ""
42
- # Only literal local filenames; never follow arbitrary links from a listing.
43
- if DETAILS_NAME.fullmatch(href):
44
- self._name = href
45
-
46
- def handle_data(self, data: str) -> None:
47
- if self._in_cell:
48
- self._cells[-1] += data
49
-
50
- def handle_endtag(self, tag: str) -> None:
51
- if tag == "td":
52
- self._in_cell = False
53
- elif tag == "tr" and self._name is not None:
54
- size = self._cells[2].strip() if len(self._cells) > 2 else ""
55
- self.files.append(
56
- (self._name, int(size) if size.isascii() and size.isdigit() else None)
57
- )
58
-
59
-
60
24
  class StormEvents(_HttpProvider):
61
25
  """Resolve whole-year details archives; preserve the original gzip bytes."""
62
26
 
63
27
  def list_assets(self, query: Query) -> list[Asset]:
64
28
  """Select the latest supported details revision for every requested year."""
65
- if unknown := set(query.params) - set(self.accepted_params):
66
- raise QueryError(f"unsupported Storm Events params: {', '.join(sorted(unknown))}")
67
- if query.bbox is not None or query.variables or query.text:
68
- raise QueryError(
69
- "Storm Events downloads whole annual details files; location/bbox, variables, "
70
- "and text filters are unsupported. Filter locally after opening the CSV."
71
- )
72
- if query.time is None or query.time.start is None or query.time.end is None:
73
- raise QueryError(f"{self.dataset.id} requires both start and end dates")
74
- start = query.time.start.replace(tzinfo=query.time.start.tzinfo or UTC).astimezone(UTC)
75
- end = query.time.end.replace(tzinfo=query.time.end.tzinfo or UTC).astimezone(UTC)
29
+ self.check_params(query)
30
+ self.reject(
31
+ query,
32
+ "bbox",
33
+ "variables",
34
+ "text",
35
+ hint="Storm Events downloads whole annual details files; filter locally after "
36
+ "opening the CSV",
37
+ )
38
+ start, end = self.utc_window(query)
76
39
  if start.year < 1950:
77
40
  raise QueryError("Storm Events annual details files start in 1950")
78
41
  years = range(start.year, end.year + 1)
79
- listing = _Directory()
80
- listing.feed(http.get(DIRECTORY_URL, self._http()).text)
42
+ page = http.get(DIRECTORY_URL, self._http()).text
81
43
  selected: dict[int, tuple[str, int | None]] = {}
82
- for name, size in listing.files:
44
+ for name, size in directory_entries(page, DETAILS_NAME):
83
45
  match = DETAILS_NAME.fullmatch(name)
84
46
  assert match is not None
85
47
  year = int(match[1])
@@ -5,8 +5,10 @@ prefix), and ``statistic_id`` (default 00003: daily mean). Variables are
5
5
  five-digit parameter codes, such as 00060 for streamflow. Dates select inclusive
6
6
  local calendar days; time-of-day information is discarded for daily values.
7
7
 
8
- Each result page is fetched as the service's CSV representation, preserving
9
- units, qualifiers, and approval status without the volatile GeoJSON timeStamp.
8
+ Listing probes one row at each page offset to learn how many pages exist without
9
+ downloading them. Each page is then fetched as the service's CSV representation,
10
+ preserving units, qualifiers, and approval status without the volatile GeoJSON
11
+ timeStamp.
10
12
  """
11
13
 
12
14
  from __future__ import annotations
@@ -20,7 +22,7 @@ from typing import ClassVar
20
22
 
21
23
  import httpx
22
24
 
23
- from usdata.models import Asset, Protocol, Query
25
+ from usdata.models import Asset, Protocol, Query, TimeRange
24
26
  from usdata.protocols import http
25
27
  from usdata.providers._http import _HttpProvider
26
28
  from usdata.providers.base import QueryError
@@ -53,10 +55,10 @@ class WaterDaily(_HttpProvider):
53
55
 
54
56
  def list_assets(self, query: Query) -> list[Asset]:
55
57
  """Resolve site or bbox queries to paginated CSV requests."""
56
- if query.time is None or query.time.start is None or query.time.end is None:
57
- raise QueryError(f"{self.dataset.id} requires both start and end dates")
58
- if unknown := set(query.params) - set(self.accepted_params):
59
- raise QueryError(f"unsupported USGS params: {', '.join(sorted(unknown))}")
58
+ self.check_params(query)
59
+ self.reject(query, "text", hint="select sites, a location, or parameter codes")
60
+ start, end = self.utc_window(query)
61
+ query = query.model_copy(update={"time": TimeRange(start=start, end=end)})
60
62
  if "site" in query.params and "sites" in query.params:
61
63
  raise QueryError("pass only one of site or sites")
62
64
  raw_sites = query.params.get("sites", query.params.get("site"))
@@ -77,7 +79,7 @@ class WaterDaily(_HttpProvider):
77
79
  raise QueryError("variables must be five-digit USGS parameter codes, for example 00060")
78
80
  params = {
79
81
  "f": "json",
80
- "time": f"{query.time.start.date().isoformat()}/{query.time.end.date().isoformat()}",
82
+ "time": f"{start.date().isoformat()}/{end.date().isoformat()}",
81
83
  "statistic_id": statistic,
82
84
  "limit": str(PAGE_SIZE),
83
85
  }
@@ -97,20 +99,18 @@ class WaterDaily(_HttpProvider):
97
99
  assets: list[Asset] = []
98
100
  offset = 0
99
101
  while True:
100
- url = httpx.URL(ITEMS_URL, params={**params, "offset": str(offset)})
101
- response = http.get(url, self._http())
102
- response.raise_for_status()
102
+ # A one-row probe says whether a page starts here; the CSV asset is the page.
103
+ probe = httpx.URL(ITEMS_URL, params={**params, "limit": "1", "offset": str(offset)})
104
+ response = http.get(probe, self._http())
103
105
  try:
104
- body = response.json()
105
- features = body["features"]
106
+ features = response.json()["features"]
106
107
  if not isinstance(features, list):
107
108
  raise ValueError("features must be a list")
108
- links = body.get("links", [])
109
- next_url = next((link["href"] for link in links if link.get("rel") == "next"), None)
110
109
  except (ValueError, KeyError, TypeError, AttributeError) as e:
111
110
  raise httpx.DecodingError("invalid USGS page", request=response.request) from e
112
111
  if not features:
113
112
  break
113
+ url = httpx.URL(ITEMS_URL, params={**params, "offset": str(offset)})
114
114
  href = str(url.copy_set_param("f", "csv"))
115
115
  digest = hashlib.sha256(href.encode()).hexdigest()[:20]
116
116
  assets.append(
@@ -124,12 +124,10 @@ class WaterDaily(_HttpProvider):
124
124
  bbox=query.bbox,
125
125
  )
126
126
  )
127
- if next_url is None:
128
- break
129
127
  # The live service can fall back from a cursor link to offset=1 at the
130
128
  # end of a page, repeating records. Absolute offsets preserve the original
131
129
  # filters and avoid mixing pagination modes; sortby is not supported here.
132
- offset += len(features)
130
+ offset += PAGE_SIZE
133
131
  return assets
134
132
 
135
133
  def fetch(self, asset: Asset, dest: Path) -> Path:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes