swpc-kit 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,384 @@
1
+ """Transport-independent core of the clients.
2
+
3
+ The clients never talk to an HTTP library directly: they hand a URL and
4
+ headers to a :class:`Transport` (or :class:`AsyncTransport`) and get back an
5
+ :class:`HttpResponse`. Everything else lives here, shared by the sync and the
6
+ async client: conditional requests, classification of the source's replies,
7
+ parsing, and the ``max_age`` check.
8
+ """
9
+
10
+ import logging
11
+ import math
12
+ from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass, replace
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Any, Protocol
16
+
17
+ from swpc_kit import __version__
18
+ from swpc_kit.errors import SourceNotReady, StaleData, SwpcError, UnexpectedContent
19
+
20
+ DEFAULT_TIMEOUT = 30.0
21
+ """Default timeout, in seconds, for each phase of a request (see ``timeout``)."""
22
+
23
+ DEFAULT_USER_AGENT = f"swpc-kit/{__version__} (+https://github.com/Tabrigos/swpc-kit)"
24
+ """Default ``User-Agent`` header."""
25
+
26
+ _TRANSIENT_STATUSES = frozenset({408, 425, 429})
27
+
28
+ _log = logging.getLogger("swpc_kit.client")
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class HttpResponse:
33
+ """What a transport returns for one request.
34
+
35
+ Attributes:
36
+ status: HTTP status code.
37
+ headers: Response headers; names are matched case-insensitively.
38
+ content: The body, as received (not decoded).
39
+ """
40
+
41
+ status: int
42
+ headers: Mapping[str, str]
43
+ content: bytes
44
+
45
+ def header(self, name: str) -> str | None:
46
+ """Return the header ``name`` (any case), or ``None``."""
47
+ wanted = name.lower()
48
+ for key, value in self.headers.items():
49
+ if key.lower() == wanted:
50
+ return value
51
+ return None
52
+
53
+
54
+ class Transport(Protocol):
55
+ """Fetches a URL synchronously.
56
+
57
+ A transport must not follow redirects, and must raise
58
+ :class:`~swpc_kit.errors.SourceNotReady` when the source cannot be
59
+ reached (timeout, refused connection, connection cut).
60
+ """
61
+
62
+ def get(
63
+ self, url: str, *, headers: Mapping[str, str], timeout: float
64
+ ) -> HttpResponse:
65
+ """Send a GET request and return the response, whatever its status."""
66
+ ...
67
+
68
+ def close(self) -> None:
69
+ """Release the transport's resources."""
70
+ ...
71
+
72
+
73
+ class AsyncTransport(Protocol):
74
+ """Fetches a URL asynchronously; same contract as :class:`Transport`."""
75
+
76
+ async def get(
77
+ self, url: str, *, headers: Mapping[str, str], timeout: float
78
+ ) -> HttpResponse:
79
+ """Send a GET request and return the response, whatever its status."""
80
+ ...
81
+
82
+ async def aclose(self) -> None:
83
+ """Release the transport's resources."""
84
+ ...
85
+
86
+
87
+ class _Timed(Protocol):
88
+ @property
89
+ def data_time(self) -> datetime | None: ...
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class Product[M: _Timed]:
94
+ """A SWPC product the clients know how to fetch.
95
+
96
+ Attributes:
97
+ name: Name of the client method that fetches it.
98
+ url: Canonical URL.
99
+ parse: Parser, called with the body and the URL it came from.
100
+ """
101
+
102
+ name: str
103
+ url: str
104
+ parse: Callable[[bytes, str], M]
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class _Cached:
109
+ etag: str | None
110
+ last_modified: str | None
111
+ model: Any
112
+
113
+
114
+ def _log_skipped(name: str, url: str, model: object) -> None:
115
+ """Warn about the records a parser skipped (see ``SkippedRecord``)."""
116
+ skipped = getattr(model, "skipped", ())
117
+ if not skipped:
118
+ return
119
+ first = skipped[0]
120
+ where = f"{first.field}: " if first.field else ""
121
+ count = f"{len(skipped)} record{'s' if len(skipped) > 1 else ''} skipped"
122
+ _log.warning(
123
+ "%s from %s: %s; %srecord %d: %s%s",
124
+ name,
125
+ url,
126
+ count,
127
+ "first, " if len(skipped) > 1 else "",
128
+ first.index,
129
+ where,
130
+ first.reason,
131
+ )
132
+
133
+
134
+ def _utc_now() -> datetime:
135
+ return datetime.now(UTC)
136
+
137
+
138
+ def _check_arguments(
139
+ *,
140
+ user_agent: object,
141
+ timeout: object,
142
+ max_age: object,
143
+ conditional: object,
144
+ clock: object,
145
+ urls: object,
146
+ ) -> None:
147
+ """Raise a clear error for an argument of the wrong type or value."""
148
+ if isinstance(timeout, bool) or not isinstance(timeout, int | float):
149
+ raise TypeError(f"timeout must be a number of seconds, got {timeout!r}")
150
+ if not timeout > 0:
151
+ raise ValueError(f"timeout must be positive, got {timeout}")
152
+ if not math.isfinite(timeout):
153
+ raise ValueError(f"timeout must be finite, got {timeout}")
154
+ if max_age is not None:
155
+ if not isinstance(max_age, timedelta):
156
+ raise TypeError(f"max_age must be a timedelta or None, got {max_age!r}")
157
+ if max_age <= timedelta(0):
158
+ raise ValueError(f"max_age must be positive, got {max_age}")
159
+ if not isinstance(user_agent, str):
160
+ raise TypeError(f"user_agent must be a string, got {user_agent!r}")
161
+ if not user_agent.strip():
162
+ raise ValueError("user_agent must not be empty")
163
+ if "\r" in user_agent or "\n" in user_agent:
164
+ raise ValueError(f"user_agent must be one line, got {user_agent!r}")
165
+ if not isinstance(conditional, bool):
166
+ raise TypeError(f"conditional must be True or False, got {conditional!r}")
167
+ if not callable(clock):
168
+ raise TypeError(f"clock must be callable, got {clock!r}")
169
+ if urls is None:
170
+ return
171
+ if not isinstance(urls, Mapping):
172
+ raise TypeError(f"urls must be a mapping of product to URL, got {urls!r}")
173
+ for name, url in urls.items():
174
+ if not isinstance(url, str):
175
+ raise TypeError(f"URL of {name} must be a string, got {url!r}")
176
+ if not url.startswith(("https://", "http://")):
177
+ raise ValueError(
178
+ f"URL of {name} must start with https:// or http://, got {url!r}"
179
+ )
180
+
181
+
182
+ def check_transport(client: str, transport: object, *methods: str) -> None:
183
+ """Raise :class:`TypeError` if ``transport`` lacks one of ``methods``.
184
+
185
+ Args:
186
+ client: Name of the client class, for the message.
187
+ transport: The transport given to the client.
188
+ methods: The methods the client needs: ``get`` and ``close`` or
189
+ ``aclose``.
190
+ """
191
+ if all(callable(getattr(transport, method, None)) for method in methods):
192
+ return
193
+ wanted = "a Transport" if client == "Client" else "an AsyncTransport"
194
+ raise TypeError(
195
+ f"{client} needs {wanted} (with {' and '.join(methods)}),"
196
+ f" got {type(transport).__name__}"
197
+ )
198
+
199
+
200
+ class BaseClient:
201
+ """State and logic shared by :class:`Client` and :class:`AsyncClient`.
202
+
203
+ Args:
204
+ user_agent: ``User-Agent`` header sent with every request.
205
+ timeout: Timeout in seconds for each phase of a request, handed to
206
+ the transport (see :class:`~swpc_kit.client.Client`).
207
+ max_age: Default maximum age of the data (see :meth:`check_age`);
208
+ ``None`` means no check.
209
+ conditional: Send ``If-None-Match`` / ``If-Modified-Since`` when a
210
+ previous reply for the same URL carried ``ETag`` /
211
+ ``Last-Modified``, and reuse that reply's model on ``304``.
212
+ clock: Returns the current time, as an aware UTC datetime.
213
+ urls: URLs that replace the canonical ones, by product name, for a
214
+ product that SWPC moved before a new version of swpc-kit knows.
215
+
216
+ Raises:
217
+ TypeError: If an argument has the wrong type.
218
+ ValueError: If ``timeout`` or ``max_age`` is not positive, the user
219
+ agent is empty or not one line, or ``urls`` names a product that
220
+ does not exist or gives a URL that is not HTTP.
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ *,
226
+ user_agent: str,
227
+ timeout: float,
228
+ max_age: timedelta | None,
229
+ conditional: bool,
230
+ clock: Callable[[], datetime],
231
+ urls: Mapping[str, str] | None = None,
232
+ ) -> None:
233
+ from swpc_kit.client._products import PRODUCTS
234
+
235
+ _check_arguments(
236
+ user_agent=user_agent,
237
+ timeout=timeout,
238
+ max_age=max_age,
239
+ conditional=conditional,
240
+ clock=clock,
241
+ urls=urls,
242
+ )
243
+ self.urls: dict[str, str] = dict(urls or {})
244
+ unknown = sorted(set(self.urls) - {product.name for product in PRODUCTS})
245
+ if unknown:
246
+ raise ValueError(f"no such product in urls: {', '.join(unknown)}")
247
+ self.user_agent = user_agent
248
+ self.timeout = timeout
249
+ self.max_age = max_age
250
+ self.conditional = conditional
251
+ self._clock = clock
252
+ self._cache: dict[str, _Cached] = {}
253
+
254
+ def resolve[M: _Timed](self, product: Product[M]) -> Product[M]:
255
+ """Return ``product`` with the URL that ``urls`` gives it, if any."""
256
+ url = self.urls.get(product.name)
257
+ return product if url is None else replace(product, url=url)
258
+
259
+ @staticmethod
260
+ def locate(error: SwpcError, product: Product[Any]) -> None:
261
+ """Record in ``error`` which product and URL it comes from."""
262
+ if error.product is None:
263
+ error.product = product.name
264
+ error.url = product.url
265
+
266
+ def request_headers(self, product: Product[Any]) -> dict[str, str]:
267
+ """Return the headers for a request of ``product``."""
268
+ headers = {"User-Agent": self.user_agent}
269
+ cached = self._cache.get(product.url) if self.conditional else None
270
+ if cached is not None:
271
+ if cached.etag is not None:
272
+ headers["If-None-Match"] = cached.etag
273
+ if cached.last_modified is not None:
274
+ headers["If-Modified-Since"] = cached.last_modified
275
+ return headers
276
+
277
+ def handle[M: _Timed](
278
+ self,
279
+ product: Product[M],
280
+ response: HttpResponse,
281
+ max_age: timedelta | None,
282
+ ) -> M:
283
+ """Turn a response into a model, or raise the matching error.
284
+
285
+ Args:
286
+ product: The product requested.
287
+ response: What the transport returned.
288
+ max_age: Maximum age for this call; ``None`` uses the client's.
289
+
290
+ Returns:
291
+ The parsed model, or the cached one on ``304 Not Modified``.
292
+
293
+ Raises:
294
+ SourceNotReady: Temporary failure (``429``, ``5xx``…) or
295
+ truncated content.
296
+ UnexpectedContent: Redirect, other status, HTML instead of data,
297
+ or content no parser knows.
298
+ ParseError: Known format with an inconsistent value.
299
+ StaleData: The data is older than the maximum age.
300
+ """
301
+ model = self.reuse(product, response)
302
+ if model is None:
303
+ model = product.parse(response.content, product.url)
304
+ self.remember(product, response, model)
305
+ self.check_age(model, max_age)
306
+ return model
307
+
308
+ def reuse[M: _Timed](self, product: Product[M], response: HttpResponse) -> M | None:
309
+ """Return the cached model on ``304``, or ``None`` if the body is new.
310
+
311
+ The first half of :meth:`handle`: the async client runs it on the
312
+ event loop, then parses the body in a thread.
313
+
314
+ Raises:
315
+ SourceNotReady: Temporary failure (``429``, ``5xx``…).
316
+ UnexpectedContent: Redirect, other status, HTML instead of data,
317
+ or ``304`` with no copy.
318
+ """
319
+ url = product.url
320
+ status = response.status
321
+ if status == 304:
322
+ cached = self._cache.get(url)
323
+ if cached is None:
324
+ raise UnexpectedContent(f"304 Not Modified from {url} with no copy")
325
+ model: M = cached.model
326
+ _log.debug("304 Not Modified from %s: reusing %s", url, product.name)
327
+ return model
328
+ if status in _TRANSIENT_STATUSES or 500 <= status <= 599:
329
+ raise SourceNotReady(f"HTTP {status} from {url}; try again later")
330
+ if 300 <= status <= 399:
331
+ location = response.header("Location")
332
+ target = location or "an unknown place"
333
+ raise UnexpectedContent(
334
+ f"HTTP {status} from {url}: moved to {target}",
335
+ status=status,
336
+ location=location,
337
+ )
338
+ if status != 200:
339
+ raise UnexpectedContent(f"HTTP {status} from {url}", status=status)
340
+ content_type = response.header("Content-Type") or ""
341
+ if "html" in content_type.lower():
342
+ raise UnexpectedContent(f"got HTML ({content_type}) from {url}")
343
+ return None
344
+
345
+ def remember(
346
+ self, product: Product[Any], response: HttpResponse, model: object
347
+ ) -> None:
348
+ """Log the skipped records of a new model and keep it for ``304``."""
349
+ url = product.url
350
+ _log_skipped(product.name, url, model)
351
+ etag = response.header("ETag")
352
+ last_modified = response.header("Last-Modified")
353
+ if etag is not None or last_modified is not None:
354
+ self._cache[url] = _Cached(etag, last_modified, model)
355
+ else:
356
+ self._cache.pop(url, None)
357
+
358
+ def check_age(self, model: _Timed, max_age: timedelta | None) -> None:
359
+ """Raise :class:`StaleData` if ``model`` is older than allowed.
360
+
361
+ The age is measured on ``model.data_time``, the time the content
362
+ declares, never on HTTP metadata. A model with no ``data_time`` (an
363
+ empty list of alerts) cannot be stale.
364
+
365
+ Args:
366
+ model: The model to check.
367
+ max_age: Maximum age; ``None`` uses the client's default.
368
+
369
+ Raises:
370
+ StaleData: If the data is too old.
371
+ ValueError: If the clock does not return a UTC datetime.
372
+ """
373
+ limit = max_age if max_age is not None else self.max_age
374
+ if limit is None:
375
+ return
376
+ data_time = model.data_time
377
+ if data_time is None:
378
+ return
379
+ now = self._clock()
380
+ if now.utcoffset() != timedelta(0):
381
+ raise ValueError(f"clock must return an aware datetime in UTC, got {now!r}")
382
+ age = now - data_time
383
+ if age > limit:
384
+ raise StaleData(data_time=data_time, max_age=limit, age=age)
@@ -0,0 +1,93 @@
1
+ """Transports built on httpx2, the only module that knows the HTTP library.
2
+
3
+ httpx2 is the maintained continuation of httpx (see ``docs/FONTI.md``,
4
+ section "HTTP client"). Switching library means rewriting this module only:
5
+ the clients depend on the :class:`~swpc_kit.client.Transport` protocol.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+
10
+ try:
11
+ import httpx2
12
+ except ImportError as exc:
13
+ raise ImportError(
14
+ "the default transports of swpc_kit.client need httpx2:"
15
+ " pip install 'swpc-kit[client]'"
16
+ ) from exc
17
+
18
+ from swpc_kit.client._core import HttpResponse
19
+ from swpc_kit.errors import SourceNotReady
20
+
21
+
22
+ def _response(response: httpx2.Response) -> HttpResponse:
23
+ return HttpResponse(
24
+ status=response.status_code,
25
+ headers=dict(response.headers),
26
+ content=response.content,
27
+ )
28
+
29
+
30
+ def _not_ready(url: str, exc: httpx2.HTTPError) -> SourceNotReady:
31
+ return SourceNotReady(f"cannot fetch {url}: {type(exc).__name__}: {exc}")
32
+
33
+
34
+ class HttpxTransport:
35
+ """Synchronous transport on an ``httpx2.Client``.
36
+
37
+ Redirects are never followed. Timeouts, network failures and any other
38
+ httpx2 error (a body that does not decode, for one) become
39
+ :class:`~swpc_kit.errors.SourceNotReady`.
40
+
41
+ Args:
42
+ client: The ``httpx2.Client`` to use; by default a new one, closed by
43
+ :meth:`close`.
44
+ """
45
+
46
+ def __init__(self, client: httpx2.Client | None = None) -> None:
47
+ self.client = client if client is not None else httpx2.Client()
48
+
49
+ def get(
50
+ self, url: str, *, headers: Mapping[str, str], timeout: float
51
+ ) -> HttpResponse:
52
+ """Send a GET request and return the response, whatever its status."""
53
+ try:
54
+ response = self.client.get(
55
+ url, headers=dict(headers), timeout=timeout, follow_redirects=False
56
+ )
57
+ except httpx2.HTTPError as exc:
58
+ raise _not_ready(url, exc) from exc
59
+ return _response(response)
60
+
61
+ def close(self) -> None:
62
+ """Close the underlying ``httpx2.Client``."""
63
+ self.client.close()
64
+
65
+
66
+ class AsyncHttpxTransport:
67
+ """Asynchronous transport on an ``httpx2.AsyncClient``.
68
+
69
+ Same behaviour as :class:`HttpxTransport`.
70
+
71
+ Args:
72
+ client: The ``httpx2.AsyncClient`` to use; by default a new one,
73
+ closed by :meth:`aclose`.
74
+ """
75
+
76
+ def __init__(self, client: httpx2.AsyncClient | None = None) -> None:
77
+ self.client = client if client is not None else httpx2.AsyncClient()
78
+
79
+ async def get(
80
+ self, url: str, *, headers: Mapping[str, str], timeout: float
81
+ ) -> HttpResponse:
82
+ """Send a GET request and return the response, whatever its status."""
83
+ try:
84
+ response = await self.client.get(
85
+ url, headers=dict(headers), timeout=timeout, follow_redirects=False
86
+ )
87
+ except httpx2.HTTPError as exc:
88
+ raise _not_ready(url, exc) from exc
89
+ return _response(response)
90
+
91
+ async def aclose(self) -> None:
92
+ """Close the underlying ``httpx2.AsyncClient``."""
93
+ await self.client.aclose()
@@ -0,0 +1,146 @@
1
+ """The products the clients fetch: URL and parser of each."""
2
+
3
+ from typing import Any
4
+
5
+ from swpc_kit.client._core import Product
6
+ from swpc_kit.parse.alerts import (
7
+ ALERTS_URL,
8
+ parse_alerts,
9
+ )
10
+ from swpc_kit.parse.bulletins import (
11
+ GEOMAGNETIC_FORECAST_URL,
12
+ THREE_DAY_FORECAST_URL,
13
+ TWENTY_SEVEN_DAY_OUTLOOK_URL,
14
+ parse_27_day_outlook,
15
+ parse_geomagnetic_forecast,
16
+ parse_three_day_forecast,
17
+ )
18
+ from swpc_kit.parse.goes import (
19
+ GOES_INTEGRAL_PROTONS_1_DAY_URL,
20
+ GOES_XRAYS_1_DAY_URL,
21
+ parse_goes_integral_protons,
22
+ parse_goes_xrays,
23
+ )
24
+ from swpc_kit.parse.grids import (
25
+ DRAP_GLOBAL_FREQUENCIES_URL,
26
+ OVATION_AURORA_URL,
27
+ parse_drap_global_frequencies,
28
+ parse_ovation_aurora,
29
+ )
30
+ from swpc_kit.parse.indices import (
31
+ F107_FLUX_30_DAY_URL,
32
+ KYOTO_DST_URL,
33
+ NOAA_SCALES_URL,
34
+ PLANETARY_K_INDEX_1M_URL,
35
+ PLANETARY_K_INDEX_FORECAST_URL,
36
+ PLANETARY_K_INDEX_URL,
37
+ parse_f107_flux_30_day,
38
+ parse_kyoto_dst,
39
+ parse_noaa_scales,
40
+ parse_planetary_k_index,
41
+ parse_planetary_k_index_1m,
42
+ parse_planetary_k_index_forecast,
43
+ )
44
+ from swpc_kit.parse.solar_wind import (
45
+ RTSW_MAG_1M_URL,
46
+ RTSW_WIND_1M_URL,
47
+ parse_rtsw_mag_1m,
48
+ parse_rtsw_wind_1m,
49
+ )
50
+
51
+ PLANETARY_K_INDEX = Product(
52
+ "planetary_k_index",
53
+ PLANETARY_K_INDEX_URL,
54
+ lambda raw, url: parse_planetary_k_index(raw, source_url=url),
55
+ )
56
+ PLANETARY_K_INDEX_FORECAST = Product(
57
+ "planetary_k_index_forecast",
58
+ PLANETARY_K_INDEX_FORECAST_URL,
59
+ lambda raw, url: parse_planetary_k_index_forecast(raw, source_url=url),
60
+ )
61
+ PLANETARY_K_INDEX_1M = Product(
62
+ "planetary_k_index_1m",
63
+ PLANETARY_K_INDEX_1M_URL,
64
+ lambda raw, url: parse_planetary_k_index_1m(raw, source_url=url),
65
+ )
66
+ KYOTO_DST = Product(
67
+ "kyoto_dst", KYOTO_DST_URL, lambda raw, url: parse_kyoto_dst(raw, source_url=url)
68
+ )
69
+ F107_FLUX_30_DAY = Product(
70
+ "f107_flux_30_day",
71
+ F107_FLUX_30_DAY_URL,
72
+ lambda raw, url: parse_f107_flux_30_day(raw, source_url=url),
73
+ )
74
+ NOAA_SCALES = Product(
75
+ "noaa_scales",
76
+ NOAA_SCALES_URL,
77
+ lambda raw, url: parse_noaa_scales(raw, source_url=url),
78
+ )
79
+ RTSW_MAG_1M = Product(
80
+ "rtsw_mag_1m",
81
+ RTSW_MAG_1M_URL,
82
+ lambda raw, url: parse_rtsw_mag_1m(raw, source_url=url),
83
+ )
84
+ RTSW_WIND_1M = Product(
85
+ "rtsw_wind_1m",
86
+ RTSW_WIND_1M_URL,
87
+ lambda raw, url: parse_rtsw_wind_1m(raw, source_url=url),
88
+ )
89
+ GOES_XRAYS = Product(
90
+ "goes_xrays",
91
+ GOES_XRAYS_1_DAY_URL,
92
+ lambda raw, url: parse_goes_xrays(raw, source_url=url),
93
+ )
94
+ GOES_INTEGRAL_PROTONS = Product(
95
+ "goes_integral_protons",
96
+ GOES_INTEGRAL_PROTONS_1_DAY_URL,
97
+ lambda raw, url: parse_goes_integral_protons(raw, source_url=url),
98
+ )
99
+ ALERTS = Product(
100
+ "alerts", ALERTS_URL, lambda raw, url: parse_alerts(raw, source_url=url)
101
+ )
102
+ THREE_DAY_FORECAST = Product(
103
+ "three_day_forecast",
104
+ THREE_DAY_FORECAST_URL,
105
+ lambda raw, url: parse_three_day_forecast(raw, source_url=url),
106
+ )
107
+ GEOMAGNETIC_FORECAST = Product(
108
+ "geomagnetic_forecast",
109
+ GEOMAGNETIC_FORECAST_URL,
110
+ lambda raw, url: parse_geomagnetic_forecast(raw, source_url=url),
111
+ )
112
+ TWENTY_SEVEN_DAY_OUTLOOK = Product(
113
+ "twenty_seven_day_outlook",
114
+ TWENTY_SEVEN_DAY_OUTLOOK_URL,
115
+ lambda raw, url: parse_27_day_outlook(raw, source_url=url),
116
+ )
117
+ OVATION_AURORA = Product(
118
+ "ovation_aurora",
119
+ OVATION_AURORA_URL,
120
+ lambda raw, url: parse_ovation_aurora(raw, source_url=url),
121
+ )
122
+ DRAP_GLOBAL_FREQUENCIES = Product(
123
+ "drap_global_frequencies",
124
+ DRAP_GLOBAL_FREQUENCIES_URL,
125
+ lambda raw, url: parse_drap_global_frequencies(raw, source_url=url),
126
+ )
127
+
128
+ PRODUCTS: tuple[Product[Any], ...] = (
129
+ PLANETARY_K_INDEX,
130
+ PLANETARY_K_INDEX_FORECAST,
131
+ PLANETARY_K_INDEX_1M,
132
+ KYOTO_DST,
133
+ F107_FLUX_30_DAY,
134
+ NOAA_SCALES,
135
+ RTSW_MAG_1M,
136
+ RTSW_WIND_1M,
137
+ GOES_XRAYS,
138
+ GOES_INTEGRAL_PROTONS,
139
+ ALERTS,
140
+ THREE_DAY_FORECAST,
141
+ GEOMAGNETIC_FORECAST,
142
+ TWENTY_SEVEN_DAY_OUTLOOK,
143
+ OVATION_AURORA,
144
+ DRAP_GLOBAL_FREQUENCIES,
145
+ )
146
+ """Every product, in the order of ``docs/TAPPE.md``."""