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.
swpc_kit/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Typed readers for the public data products of NOAA SWPC.
2
+
3
+ The package has three layers, each usable on its own:
4
+
5
+ - :mod:`swpc_kit.parse`: pure functions that turn raw ``str``/``bytes`` into
6
+ models (standard library only);
7
+ - :mod:`swpc_kit.models`: frozen dataclasses with UTC ``datetime`` fields;
8
+ - outputs: :mod:`swpc_kit.export` (GeoJSON, GeoTIFF) and
9
+ :mod:`swpc_kit.client` (HTTP access, ``client`` extra).
10
+
11
+ Data comes from NOAA SWPC (https://www.swpc.noaa.gov). This project is not
12
+ affiliated with or endorsed by NOAA.
13
+ """
14
+
15
+ from swpc_kit.errors import (
16
+ ParseError,
17
+ SourceNotReady,
18
+ StaleData,
19
+ SwpcError,
20
+ UnexpectedContent,
21
+ )
22
+
23
+ __version__ = "0.3.0"
24
+
25
+ __all__ = [
26
+ "ParseError",
27
+ "SourceNotReady",
28
+ "StaleData",
29
+ "SwpcError",
30
+ "UnexpectedContent",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,482 @@
1
+ """HTTP client for SWPC products (``client`` extra).
2
+
3
+ :class:`Client` and :class:`AsyncClient` have one method per product. Each
4
+ downloads the product, recognises the errors of the source, hands the content
5
+ to the matching parser and, if asked, checks that the data is recent enough::
6
+
7
+ from datetime import timedelta
8
+ from swpc_kit.client import Client
9
+
10
+ with Client() as client:
11
+ kp = client.planetary_k_index(max_age=timedelta(hours=6))
12
+
13
+ Errors (all :class:`~swpc_kit.errors.SwpcError`):
14
+
15
+ - :class:`~swpc_kit.errors.SourceNotReady`: timeout, network failure,
16
+ ``408``/``425``/``429``/``5xx``, or truncated content; try again later;
17
+ - :class:`~swpc_kit.errors.UnexpectedContent`: redirect (never followed),
18
+ other status, HTML, or content no parser knows;
19
+ - :class:`~swpc_kit.errors.ParseError`: known format, inconsistent value;
20
+ - :class:`~swpc_kit.errors.StaleData`: the content declares a time older
21
+ than ``max_age``.
22
+
23
+ Requests are conditional: once a URL answered with ``ETag`` or
24
+ ``Last-Modified``, the next request sends them back, and a ``304 Not
25
+ Modified`` reuses the previous model (``max_age`` is still checked).
26
+
27
+ If SWPC moves a product before a new version of swpc-kit knows the new
28
+ address, ``urls`` points the client there::
29
+
30
+ Client(urls={"kyoto_dst": "https://services.swpc.noaa.gov/new/dst.json"})
31
+
32
+ Every error that goes through the client says which product and URL it comes
33
+ from (``product`` and ``url`` attributes, and in the message).
34
+
35
+ The HTTP library (httpx2) is used only by the default transports
36
+ (:class:`HttpxTransport`, :class:`AsyncHttpxTransport`) and imported only
37
+ when one is needed; any object with the :class:`Transport` or
38
+ :class:`AsyncTransport` interface can replace them.
39
+ """
40
+
41
+ import asyncio
42
+ from collections.abc import Callable, Mapping
43
+ from datetime import datetime, timedelta
44
+ from types import TracebackType
45
+ from typing import TYPE_CHECKING, Any, Self
46
+
47
+ from swpc_kit.client import _products as p
48
+ from swpc_kit.client._core import (
49
+ DEFAULT_TIMEOUT,
50
+ DEFAULT_USER_AGENT,
51
+ AsyncTransport,
52
+ BaseClient,
53
+ HttpResponse,
54
+ Product,
55
+ Transport,
56
+ _Timed,
57
+ _utc_now,
58
+ check_transport,
59
+ )
60
+ from swpc_kit.client._products import PRODUCTS
61
+ from swpc_kit.errors import SwpcError
62
+ from swpc_kit.models.alerts import (
63
+ Alerts,
64
+ )
65
+ from swpc_kit.models.bulletins import (
66
+ GeomagneticForecast,
67
+ ThreeDayForecast,
68
+ TwentySevenDayOutlook,
69
+ )
70
+ from swpc_kit.models.goes import (
71
+ GoesIntegralProtons,
72
+ GoesXrays,
73
+ )
74
+ from swpc_kit.models.grids import (
75
+ DrapFrequencies,
76
+ OvationAurora,
77
+ )
78
+ from swpc_kit.models.indices import (
79
+ F107Flux,
80
+ KyotoDst,
81
+ NoaaScales,
82
+ PlanetaryKIndex,
83
+ PlanetaryKIndex1m,
84
+ PlanetaryKIndexForecast,
85
+ )
86
+ from swpc_kit.models.solar_wind import (
87
+ RtswMag1m,
88
+ RtswWind1m,
89
+ )
90
+
91
+ if TYPE_CHECKING:
92
+ from swpc_kit.client._httpx import AsyncHttpxTransport, HttpxTransport
93
+
94
+ __all__ = [
95
+ "DEFAULT_TIMEOUT",
96
+ "DEFAULT_USER_AGENT",
97
+ "PRODUCTS",
98
+ "AsyncClient",
99
+ "AsyncHttpxTransport",
100
+ "AsyncTransport",
101
+ "Client",
102
+ "HttpResponse",
103
+ "HttpxTransport",
104
+ "Product",
105
+ "Transport",
106
+ ]
107
+
108
+
109
+ def __getattr__(name: str) -> Any:
110
+ # The httpx2 transports load on first use: a custom transport needs no
111
+ # httpx2, and without the extra the import error says what to install.
112
+ if name in ("HttpxTransport", "AsyncHttpxTransport"):
113
+ from swpc_kit.client import _httpx
114
+
115
+ return getattr(_httpx, name)
116
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
117
+
118
+
119
+ class Client(BaseClient):
120
+ """Synchronous client for SWPC products.
121
+
122
+ Use it with ``with`` or call :meth:`close`.
123
+
124
+ Args:
125
+ transport: How requests are sent; by default an
126
+ :class:`HttpxTransport` (closed with the client).
127
+ user_agent: ``User-Agent`` header sent with every request.
128
+ timeout: Timeout in seconds for each phase of a request: connecting,
129
+ sending, waiting for a free connection, and each wait for the
130
+ next piece of the reply, as httpx2 applies it. It does not
131
+ bound the whole request: a server that keeps sending slowly can
132
+ make one last longer.
133
+ max_age: Default maximum age of the data; ``None`` means no check.
134
+ Each method also takes ``max_age``, which wins over this one.
135
+ conditional: Use ``ETag``/``Last-Modified`` conditional requests.
136
+ clock: Returns the current time as an aware UTC datetime (for
137
+ ``max_age``).
138
+ urls: URLs that replace the canonical ones, by method name, for a
139
+ product that SWPC moved: ``{"kyoto_dst": "https://..."}``.
140
+
141
+ Raises:
142
+ TypeError: If an argument has the wrong type, or ``transport`` lacks
143
+ the methods it needs.
144
+ ValueError: If ``timeout`` or ``max_age`` is not positive, the user
145
+ agent is empty or not one line, or ``urls`` names a method that
146
+ does not exist or gives a URL that is not HTTP.
147
+ ImportError: If no ``transport`` is given and httpx2 (the
148
+ ``client`` extra) is not installed.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ *,
154
+ transport: Transport | None = None,
155
+ user_agent: str = DEFAULT_USER_AGENT,
156
+ timeout: float = DEFAULT_TIMEOUT,
157
+ max_age: timedelta | None = None,
158
+ conditional: bool = True,
159
+ clock: Callable[[], datetime] = _utc_now,
160
+ urls: Mapping[str, str] | None = None,
161
+ ) -> None:
162
+ super().__init__(
163
+ user_agent=user_agent,
164
+ timeout=timeout,
165
+ max_age=max_age,
166
+ conditional=conditional,
167
+ clock=clock,
168
+ urls=urls,
169
+ )
170
+ if transport is None:
171
+ from swpc_kit.client._httpx import HttpxTransport
172
+
173
+ transport = HttpxTransport()
174
+ check_transport("Client", transport, "get", "close")
175
+ self.transport: Transport = transport
176
+
177
+ def fetch[M: _Timed](
178
+ self, product: Product[M], *, max_age: timedelta | None = None
179
+ ) -> M:
180
+ """Fetch and parse ``product`` (one of :data:`PRODUCTS`).
181
+
182
+ Args:
183
+ product: What to fetch.
184
+ max_age: Maximum age of the data; ``None`` uses the client's.
185
+
186
+ Returns:
187
+ The parsed model.
188
+ """
189
+ product = self.resolve(product)
190
+ try:
191
+ response = self.transport.get(
192
+ product.url,
193
+ headers=self.request_headers(product),
194
+ timeout=self.timeout,
195
+ )
196
+ return self.handle(product, response, max_age)
197
+ except SwpcError as error:
198
+ self.locate(error, product)
199
+ raise
200
+
201
+ def close(self) -> None:
202
+ """Close the transport."""
203
+ self.transport.close()
204
+
205
+ def __enter__(self) -> Self:
206
+ return self
207
+
208
+ def __exit__(
209
+ self,
210
+ exc_type: type[BaseException] | None,
211
+ exc: BaseException | None,
212
+ tb: TracebackType | None,
213
+ ) -> None:
214
+ self.close()
215
+
216
+ def planetary_k_index(self, *, max_age: timedelta | None = None) -> PlanetaryKIndex:
217
+ """Fetch the 3-hour planetary K index of the last days."""
218
+ return self.fetch(p.PLANETARY_K_INDEX, max_age=max_age)
219
+
220
+ def planetary_k_index_forecast(
221
+ self, *, max_age: timedelta | None = None
222
+ ) -> PlanetaryKIndexForecast:
223
+ """Fetch the observed and forecast 3-hour Kp."""
224
+ return self.fetch(p.PLANETARY_K_INDEX_FORECAST, max_age=max_age)
225
+
226
+ def planetary_k_index_1m(
227
+ self, *, max_age: timedelta | None = None
228
+ ) -> PlanetaryKIndex1m:
229
+ """Fetch the 1-minute Kp estimates."""
230
+ return self.fetch(p.PLANETARY_K_INDEX_1M, max_age=max_age)
231
+
232
+ def kyoto_dst(self, *, max_age: timedelta | None = None) -> KyotoDst:
233
+ """Fetch the hourly Kyoto Dst of the last week."""
234
+ return self.fetch(p.KYOTO_DST, max_age=max_age)
235
+
236
+ def f107_flux_30_day(self, *, max_age: timedelta | None = None) -> F107Flux:
237
+ """Fetch the daily F10.7 of the last 30 days."""
238
+ return self.fetch(p.F107_FLUX_30_DAY, max_age=max_age)
239
+
240
+ def noaa_scales(self, *, max_age: timedelta | None = None) -> NoaaScales:
241
+ """Fetch the NOAA space weather scales."""
242
+ return self.fetch(p.NOAA_SCALES, max_age=max_age)
243
+
244
+ def rtsw_mag_1m(self, *, max_age: timedelta | None = None) -> RtswMag1m:
245
+ """Fetch the real-time solar wind magnetic field."""
246
+ return self.fetch(p.RTSW_MAG_1M, max_age=max_age)
247
+
248
+ def rtsw_wind_1m(self, *, max_age: timedelta | None = None) -> RtswWind1m:
249
+ """Fetch the real-time solar wind plasma."""
250
+ return self.fetch(p.RTSW_WIND_1M, max_age=max_age)
251
+
252
+ def goes_xrays(self, *, max_age: timedelta | None = None) -> GoesXrays:
253
+ """Fetch the GOES X-ray flux of the last day (primary satellite)."""
254
+ return self.fetch(p.GOES_XRAYS, max_age=max_age)
255
+
256
+ def goes_integral_protons(
257
+ self, *, max_age: timedelta | None = None
258
+ ) -> GoesIntegralProtons:
259
+ """Fetch the GOES integral proton flux of the last day (primary satellite)."""
260
+ return self.fetch(p.GOES_INTEGRAL_PROTONS, max_age=max_age)
261
+
262
+ def alerts(self, *, max_age: timedelta | None = None) -> Alerts:
263
+ """Fetch the alerts, warnings, watches and summaries."""
264
+ return self.fetch(p.ALERTS, max_age=max_age)
265
+
266
+ def three_day_forecast(
267
+ self, *, max_age: timedelta | None = None
268
+ ) -> ThreeDayForecast:
269
+ """Fetch the 3-day forecast."""
270
+ return self.fetch(p.THREE_DAY_FORECAST, max_age=max_age)
271
+
272
+ def geomagnetic_forecast(
273
+ self, *, max_age: timedelta | None = None
274
+ ) -> GeomagneticForecast:
275
+ """Fetch the 3-day geomagnetic forecast."""
276
+ return self.fetch(p.GEOMAGNETIC_FORECAST, max_age=max_age)
277
+
278
+ def twenty_seven_day_outlook(
279
+ self, *, max_age: timedelta | None = None
280
+ ) -> TwentySevenDayOutlook:
281
+ """Fetch the 27-day outlook."""
282
+ return self.fetch(p.TWENTY_SEVEN_DAY_OUTLOOK, max_age=max_age)
283
+
284
+ def ovation_aurora(self, *, max_age: timedelta | None = None) -> OvationAurora:
285
+ """Fetch the OVATION aurora forecast grid."""
286
+ return self.fetch(p.OVATION_AURORA, max_age=max_age)
287
+
288
+ def drap_global_frequencies(
289
+ self, *, max_age: timedelta | None = None
290
+ ) -> DrapFrequencies:
291
+ """Fetch the D-RAP global frequency grid."""
292
+ return self.fetch(p.DRAP_GLOBAL_FREQUENCIES, max_age=max_age)
293
+
294
+
295
+ class AsyncClient(BaseClient):
296
+ """Asynchronous client for SWPC products.
297
+
298
+ Use it with ``async with`` or call :meth:`aclose`. The content is parsed
299
+ in a thread (:func:`asyncio.to_thread`), so a large file such as the
300
+ OVATION grid does not block the event loop. For a limit on the whole
301
+ call, wrap it in :func:`asyncio.timeout`.
302
+
303
+ Args:
304
+ transport: How requests are sent; by default an
305
+ :class:`AsyncHttpxTransport` (closed with the client).
306
+ user_agent: ``User-Agent`` header sent with every request.
307
+ timeout: Timeout in seconds for each phase of a request: connecting,
308
+ sending, waiting for a free connection, and each wait for the
309
+ next piece of the reply, as httpx2 applies it. It does not
310
+ bound the whole request: a server that keeps sending slowly can
311
+ make one last longer.
312
+ max_age: Default maximum age of the data; ``None`` means no check.
313
+ Each method also takes ``max_age``, which wins over this one.
314
+ conditional: Use ``ETag``/``Last-Modified`` conditional requests.
315
+ clock: Returns the current time as an aware UTC datetime (for
316
+ ``max_age``).
317
+ urls: URLs that replace the canonical ones, by method name, for a
318
+ product that SWPC moved: ``{"kyoto_dst": "https://..."}``.
319
+
320
+ Raises:
321
+ TypeError: If an argument has the wrong type, or ``transport`` lacks
322
+ the methods it needs.
323
+ ValueError: If ``timeout`` or ``max_age`` is not positive, the user
324
+ agent is empty or not one line, or ``urls`` names a method that
325
+ does not exist or gives a URL that is not HTTP.
326
+ ImportError: If no ``transport`` is given and httpx2 (the
327
+ ``client`` extra) is not installed.
328
+ """
329
+
330
+ def __init__(
331
+ self,
332
+ *,
333
+ transport: AsyncTransport | None = None,
334
+ user_agent: str = DEFAULT_USER_AGENT,
335
+ timeout: float = DEFAULT_TIMEOUT,
336
+ max_age: timedelta | None = None,
337
+ conditional: bool = True,
338
+ clock: Callable[[], datetime] = _utc_now,
339
+ urls: Mapping[str, str] | None = None,
340
+ ) -> None:
341
+ super().__init__(
342
+ user_agent=user_agent,
343
+ timeout=timeout,
344
+ max_age=max_age,
345
+ conditional=conditional,
346
+ clock=clock,
347
+ urls=urls,
348
+ )
349
+ if transport is None:
350
+ from swpc_kit.client._httpx import AsyncHttpxTransport
351
+
352
+ transport = AsyncHttpxTransport()
353
+ check_transport("AsyncClient", transport, "get", "aclose")
354
+ self.transport: AsyncTransport = transport
355
+
356
+ async def fetch[M: _Timed](
357
+ self, product: Product[M], *, max_age: timedelta | None = None
358
+ ) -> M:
359
+ """Fetch and parse ``product`` (one of :data:`PRODUCTS`).
360
+
361
+ Args:
362
+ product: What to fetch.
363
+ max_age: Maximum age of the data; ``None`` uses the client's.
364
+
365
+ Returns:
366
+ The parsed model.
367
+ """
368
+ product = self.resolve(product)
369
+ try:
370
+ response = await self.transport.get(
371
+ product.url,
372
+ headers=self.request_headers(product),
373
+ timeout=self.timeout,
374
+ )
375
+ model = self.reuse(product, response)
376
+ if model is None:
377
+ model = await asyncio.to_thread(
378
+ product.parse, response.content, product.url
379
+ )
380
+ self.remember(product, response, model)
381
+ self.check_age(model, max_age)
382
+ return model
383
+ except SwpcError as error:
384
+ self.locate(error, product)
385
+ raise
386
+
387
+ async def aclose(self) -> None:
388
+ """Close the transport."""
389
+ await self.transport.aclose()
390
+
391
+ async def __aenter__(self) -> Self:
392
+ return self
393
+
394
+ async def __aexit__(
395
+ self,
396
+ exc_type: type[BaseException] | None,
397
+ exc: BaseException | None,
398
+ tb: TracebackType | None,
399
+ ) -> None:
400
+ await self.aclose()
401
+
402
+ async def planetary_k_index(
403
+ self, *, max_age: timedelta | None = None
404
+ ) -> PlanetaryKIndex:
405
+ """Fetch the 3-hour planetary K index of the last days."""
406
+ return await self.fetch(p.PLANETARY_K_INDEX, max_age=max_age)
407
+
408
+ async def planetary_k_index_forecast(
409
+ self, *, max_age: timedelta | None = None
410
+ ) -> PlanetaryKIndexForecast:
411
+ """Fetch the observed and forecast 3-hour Kp."""
412
+ return await self.fetch(p.PLANETARY_K_INDEX_FORECAST, max_age=max_age)
413
+
414
+ async def planetary_k_index_1m(
415
+ self, *, max_age: timedelta | None = None
416
+ ) -> PlanetaryKIndex1m:
417
+ """Fetch the 1-minute Kp estimates."""
418
+ return await self.fetch(p.PLANETARY_K_INDEX_1M, max_age=max_age)
419
+
420
+ async def kyoto_dst(self, *, max_age: timedelta | None = None) -> KyotoDst:
421
+ """Fetch the hourly Kyoto Dst of the last week."""
422
+ return await self.fetch(p.KYOTO_DST, max_age=max_age)
423
+
424
+ async def f107_flux_30_day(self, *, max_age: timedelta | None = None) -> F107Flux:
425
+ """Fetch the daily F10.7 of the last 30 days."""
426
+ return await self.fetch(p.F107_FLUX_30_DAY, max_age=max_age)
427
+
428
+ async def noaa_scales(self, *, max_age: timedelta | None = None) -> NoaaScales:
429
+ """Fetch the NOAA space weather scales."""
430
+ return await self.fetch(p.NOAA_SCALES, max_age=max_age)
431
+
432
+ async def rtsw_mag_1m(self, *, max_age: timedelta | None = None) -> RtswMag1m:
433
+ """Fetch the real-time solar wind magnetic field."""
434
+ return await self.fetch(p.RTSW_MAG_1M, max_age=max_age)
435
+
436
+ async def rtsw_wind_1m(self, *, max_age: timedelta | None = None) -> RtswWind1m:
437
+ """Fetch the real-time solar wind plasma."""
438
+ return await self.fetch(p.RTSW_WIND_1M, max_age=max_age)
439
+
440
+ async def goes_xrays(self, *, max_age: timedelta | None = None) -> GoesXrays:
441
+ """Fetch the GOES X-ray flux of the last day (primary satellite)."""
442
+ return await self.fetch(p.GOES_XRAYS, max_age=max_age)
443
+
444
+ async def goes_integral_protons(
445
+ self, *, max_age: timedelta | None = None
446
+ ) -> GoesIntegralProtons:
447
+ """Fetch the GOES integral proton flux of the last day (primary satellite)."""
448
+ return await self.fetch(p.GOES_INTEGRAL_PROTONS, max_age=max_age)
449
+
450
+ async def alerts(self, *, max_age: timedelta | None = None) -> Alerts:
451
+ """Fetch the alerts, warnings, watches and summaries."""
452
+ return await self.fetch(p.ALERTS, max_age=max_age)
453
+
454
+ async def three_day_forecast(
455
+ self, *, max_age: timedelta | None = None
456
+ ) -> ThreeDayForecast:
457
+ """Fetch the 3-day forecast."""
458
+ return await self.fetch(p.THREE_DAY_FORECAST, max_age=max_age)
459
+
460
+ async def geomagnetic_forecast(
461
+ self, *, max_age: timedelta | None = None
462
+ ) -> GeomagneticForecast:
463
+ """Fetch the 3-day geomagnetic forecast."""
464
+ return await self.fetch(p.GEOMAGNETIC_FORECAST, max_age=max_age)
465
+
466
+ async def twenty_seven_day_outlook(
467
+ self, *, max_age: timedelta | None = None
468
+ ) -> TwentySevenDayOutlook:
469
+ """Fetch the 27-day outlook."""
470
+ return await self.fetch(p.TWENTY_SEVEN_DAY_OUTLOOK, max_age=max_age)
471
+
472
+ async def ovation_aurora(
473
+ self, *, max_age: timedelta | None = None
474
+ ) -> OvationAurora:
475
+ """Fetch the OVATION aurora forecast grid."""
476
+ return await self.fetch(p.OVATION_AURORA, max_age=max_age)
477
+
478
+ async def drap_global_frequencies(
479
+ self, *, max_age: timedelta | None = None
480
+ ) -> DrapFrequencies:
481
+ """Fetch the D-RAP global frequency grid."""
482
+ return await self.fetch(p.DRAP_GLOBAL_FREQUENCIES, max_age=max_age)