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 +32 -0
- swpc_kit/client/__init__.py +482 -0
- swpc_kit/client/_core.py +384 -0
- swpc_kit/client/_httpx.py +93 -0
- swpc_kit/client/_products.py +146 -0
- swpc_kit/codes.py +249 -0
- swpc_kit/errors.py +135 -0
- swpc_kit/export/__init__.py +12 -0
- swpc_kit/export/_tiff.py +64 -0
- swpc_kit/export/data.py +167 -0
- swpc_kit/export/geojson.py +111 -0
- swpc_kit/export/geotiff.py +134 -0
- swpc_kit/models/__init__.py +116 -0
- swpc_kit/models/_checks.py +16 -0
- swpc_kit/models/alerts.py +205 -0
- swpc_kit/models/bulletins.py +239 -0
- swpc_kit/models/goes.py +252 -0
- swpc_kit/models/grids.py +353 -0
- swpc_kit/models/indices.py +356 -0
- swpc_kit/models/records.py +29 -0
- swpc_kit/models/solar_wind.py +274 -0
- swpc_kit/parse/__init__.py +53 -0
- swpc_kit/parse/_common.py +353 -0
- swpc_kit/parse/_text.py +334 -0
- swpc_kit/parse/alerts.py +252 -0
- swpc_kit/parse/bulletins.py +532 -0
- swpc_kit/parse/goes.py +111 -0
- swpc_kit/parse/grids.py +379 -0
- swpc_kit/parse/indices.py +344 -0
- swpc_kit/parse/solar_wind.py +154 -0
- swpc_kit/py.typed +0 -0
- swpc_kit/skill/__init__.py +213 -0
- swpc_kit/skill/__main__.py +7 -0
- swpc_kit/skill/swpc-kit/SKILL.md +261 -0
- swpc_kit/skill/swpc-kit/references/api.md +1052 -0
- swpc_kit-0.3.0.dist-info/METADATA +375 -0
- swpc_kit-0.3.0.dist-info/RECORD +39 -0
- swpc_kit-0.3.0.dist-info/WHEEL +4 -0
- swpc_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
swpc_kit/client/_core.py
ADDED
|
@@ -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``."""
|