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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Helpers shared by the parsers: decoding, error classification, fields.
|
|
2
|
+
|
|
3
|
+
The JSON products come in two shapes (see ``docs/FONTI.md``): an array of
|
|
4
|
+
objects (since 2026), or an array of arrays whose first row holds the column
|
|
5
|
+
names and whose numbers are quoted (before 2026). :func:`json_records` turns
|
|
6
|
+
both into :class:`Record` objects, whose accessors convert and check one
|
|
7
|
+
field at a time and raise :class:`ParseError` naming the field and record.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from swpc_kit.errors import ParseError, SourceNotReady, UnexpectedContent
|
|
18
|
+
from swpc_kit.models.records import SkippedRecord
|
|
19
|
+
|
|
20
|
+
_TIME = re.compile(
|
|
21
|
+
r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})?"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_DECIMAL = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
|
|
26
|
+
_INTEGER = re.compile(r"-?\d+")
|
|
27
|
+
_PARTIAL_NUMBER = re.compile(r"-?(?:\d+(?:\.\d*)?(?:[eE][+-]?\d*)?)?")
|
|
28
|
+
_LITERALS = ("true", "false", "null")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cut_inside_a_token(text: str, pos: int) -> bool:
|
|
32
|
+
"""Whether ``text`` ends inside the literal or number that starts near ``pos``.
|
|
33
|
+
|
|
34
|
+
A file cut in ``"bz": -1.`` or ``"active": fals`` fails to decode before
|
|
35
|
+
its end, but it is still a truncated file, not invalid JSON.
|
|
36
|
+
"""
|
|
37
|
+
start = pos
|
|
38
|
+
while start > 0 and (text[start - 1].isalnum() or text[start - 1] in "+-."):
|
|
39
|
+
start -= 1
|
|
40
|
+
token = text[start:]
|
|
41
|
+
if not token:
|
|
42
|
+
return False
|
|
43
|
+
if any(len(token) < len(word) and word.startswith(token) for word in _LITERALS):
|
|
44
|
+
return True
|
|
45
|
+
return _PARTIAL_NUMBER.fullmatch(token) is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decode(raw: str | bytes) -> str:
|
|
49
|
+
"""Return ``raw`` as text, without a leading byte order mark.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
UnexpectedContent: If the bytes are not UTF-8.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(raw, bytes):
|
|
55
|
+
try:
|
|
56
|
+
return raw.decode("utf-8-sig")
|
|
57
|
+
except UnicodeDecodeError as exc:
|
|
58
|
+
raise UnexpectedContent(f"content is not UTF-8 text: {exc}") from None
|
|
59
|
+
return raw.removeprefix("")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_json(raw: str | bytes) -> Any:
|
|
63
|
+
"""Decode a JSON product, classifying what is wrong with it.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The decoded JSON value.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
SourceNotReady: If the content is empty or the JSON stops early (SWPC
|
|
70
|
+
serving a file while rewriting it).
|
|
71
|
+
UnexpectedContent: If the content is HTML or XML, or not JSON.
|
|
72
|
+
"""
|
|
73
|
+
text = decode(raw)
|
|
74
|
+
stripped = text.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
raise SourceNotReady("empty content")
|
|
77
|
+
if stripped.startswith("<"):
|
|
78
|
+
raise UnexpectedContent("got HTML or XML instead of JSON")
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(text)
|
|
81
|
+
except json.JSONDecodeError as exc:
|
|
82
|
+
end = text.rstrip()
|
|
83
|
+
if (
|
|
84
|
+
exc.pos >= len(end)
|
|
85
|
+
or exc.msg.startswith("Unterminated string")
|
|
86
|
+
or _cut_inside_a_token(end, exc.pos)
|
|
87
|
+
):
|
|
88
|
+
raise SourceNotReady(f"truncated JSON: {exc}") from None
|
|
89
|
+
raise UnexpectedContent(f"not valid JSON: {exc}") from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_time(text: str) -> datetime:
|
|
93
|
+
"""Parse an ISO-like time from SWPC as an aware UTC datetime.
|
|
94
|
+
|
|
95
|
+
Accepts ``T`` or a space between date and time, optional seconds and
|
|
96
|
+
fractions, and an optional ``Z`` or offset. A time without a zone is UTC.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If the text is not such a time.
|
|
100
|
+
"""
|
|
101
|
+
if not _TIME.fullmatch(text):
|
|
102
|
+
raise ValueError(f"not a time: {text!r}")
|
|
103
|
+
try:
|
|
104
|
+
value = datetime.fromisoformat(text)
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise ValueError(f"not a valid time: {text!r}") from None
|
|
107
|
+
if value.tzinfo is None:
|
|
108
|
+
return value.replace(tzinfo=UTC)
|
|
109
|
+
return value.astimezone(UTC)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Record:
|
|
113
|
+
"""One record of a JSON product, with typed accessors.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
index: 0-based position of the record in the JSON array.
|
|
117
|
+
data: The record's fields.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
__slots__ = ("data", "index")
|
|
121
|
+
|
|
122
|
+
def __init__(self, index: int, data: Mapping[str, Any]) -> None:
|
|
123
|
+
self.index = index
|
|
124
|
+
self.data = data
|
|
125
|
+
|
|
126
|
+
def error(self, message: str, field: str) -> ParseError:
|
|
127
|
+
"""Return a :class:`ParseError` located at ``field`` of this record."""
|
|
128
|
+
return ParseError(message, field=field, index=self.index)
|
|
129
|
+
|
|
130
|
+
def missing(self, field: str) -> ParseError:
|
|
131
|
+
"""Return the error for ``field`` missing from this record.
|
|
132
|
+
|
|
133
|
+
:func:`read_records` tells a field missing from every record (a
|
|
134
|
+
format no parser knows) from one missing from some records only.
|
|
135
|
+
"""
|
|
136
|
+
return MissingField(field, self.index)
|
|
137
|
+
|
|
138
|
+
def _get(self, field: str) -> Any:
|
|
139
|
+
try:
|
|
140
|
+
return self.data[field]
|
|
141
|
+
except KeyError:
|
|
142
|
+
raise self.missing(field) from None
|
|
143
|
+
|
|
144
|
+
def get_str(self, field: str) -> str:
|
|
145
|
+
"""Return a string field."""
|
|
146
|
+
value = self._get(field)
|
|
147
|
+
if not isinstance(value, str):
|
|
148
|
+
raise self.error(f"expected a string, got {value!r}", field)
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
def get_optional_str(self, field: str) -> str | None:
|
|
152
|
+
"""Return a string field that may be ``null``."""
|
|
153
|
+
if self._get(field) is None:
|
|
154
|
+
return None
|
|
155
|
+
return self.get_str(field)
|
|
156
|
+
|
|
157
|
+
def get_time(self, field: str) -> datetime:
|
|
158
|
+
"""Return a time field as an aware UTC datetime."""
|
|
159
|
+
try:
|
|
160
|
+
return parse_time(self.get_str(field))
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
raise self.error(str(exc), field) from None
|
|
163
|
+
|
|
164
|
+
def get_float(
|
|
165
|
+
self,
|
|
166
|
+
field: str,
|
|
167
|
+
*,
|
|
168
|
+
minimum: float | None = None,
|
|
169
|
+
maximum: float | None = None,
|
|
170
|
+
) -> float:
|
|
171
|
+
"""Return a number field (a JSON number or a quoted one)."""
|
|
172
|
+
value = self._get(field)
|
|
173
|
+
number: float | None = None
|
|
174
|
+
if (isinstance(value, int | float) and not isinstance(value, bool)) or (
|
|
175
|
+
isinstance(value, str) and _DECIMAL.fullmatch(value.strip())
|
|
176
|
+
):
|
|
177
|
+
number = float(value)
|
|
178
|
+
if number is None or not math.isfinite(number):
|
|
179
|
+
raise self.error(f"expected a number, got {value!r}", field)
|
|
180
|
+
_check_range(self, field, number, minimum, maximum)
|
|
181
|
+
return number
|
|
182
|
+
|
|
183
|
+
def get_int(
|
|
184
|
+
self,
|
|
185
|
+
field: str,
|
|
186
|
+
*,
|
|
187
|
+
minimum: int | None = None,
|
|
188
|
+
maximum: int | None = None,
|
|
189
|
+
) -> int:
|
|
190
|
+
"""Return an integer field (a JSON integer or a quoted one)."""
|
|
191
|
+
value = self._get(field)
|
|
192
|
+
number: int | None = None
|
|
193
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
194
|
+
number = value
|
|
195
|
+
elif isinstance(value, str) and _INTEGER.fullmatch(value.strip()):
|
|
196
|
+
number = int(value)
|
|
197
|
+
if number is None:
|
|
198
|
+
raise self.error(f"expected an integer, got {value!r}", field)
|
|
199
|
+
_check_range(self, field, number, minimum, maximum)
|
|
200
|
+
return number
|
|
201
|
+
|
|
202
|
+
def get_optional_int(
|
|
203
|
+
self,
|
|
204
|
+
field: str,
|
|
205
|
+
*,
|
|
206
|
+
minimum: int | None = None,
|
|
207
|
+
maximum: int | None = None,
|
|
208
|
+
may_be_absent: bool = False,
|
|
209
|
+
) -> int | None:
|
|
210
|
+
"""Return an integer field that may be ``null``.
|
|
211
|
+
|
|
212
|
+
With ``may_be_absent``, a field missing from the record is ``None``
|
|
213
|
+
too: for undocumented flags that SWPC may drop without notice.
|
|
214
|
+
"""
|
|
215
|
+
if (may_be_absent and field not in self.data) or self._get(field) is None:
|
|
216
|
+
return None
|
|
217
|
+
return self.get_int(field, minimum=minimum, maximum=maximum)
|
|
218
|
+
|
|
219
|
+
def get_optional_float(
|
|
220
|
+
self,
|
|
221
|
+
field: str,
|
|
222
|
+
*,
|
|
223
|
+
minimum: float | None = None,
|
|
224
|
+
maximum: float | None = None,
|
|
225
|
+
) -> float | None:
|
|
226
|
+
"""Return a number field that may be ``null``."""
|
|
227
|
+
if self._get(field) is None:
|
|
228
|
+
return None
|
|
229
|
+
return self.get_float(field, minimum=minimum, maximum=maximum)
|
|
230
|
+
|
|
231
|
+
def get_bool(self, field: str) -> bool:
|
|
232
|
+
"""Return a JSON boolean field (quoted or numeric values are refused)."""
|
|
233
|
+
value = self._get(field)
|
|
234
|
+
if not isinstance(value, bool):
|
|
235
|
+
raise self.error(f"expected a boolean, got {value!r}", field)
|
|
236
|
+
return value
|
|
237
|
+
|
|
238
|
+
def get_optional_bool(
|
|
239
|
+
self, field: str, *, may_be_absent: bool = False
|
|
240
|
+
) -> bool | None:
|
|
241
|
+
"""Return a JSON boolean field that may be ``null`` (or absent)."""
|
|
242
|
+
if (may_be_absent and field not in self.data) or self._get(field) is None:
|
|
243
|
+
return None
|
|
244
|
+
return self.get_bool(field)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _check_range(
|
|
248
|
+
record: Record,
|
|
249
|
+
field: str,
|
|
250
|
+
value: float,
|
|
251
|
+
minimum: float | None,
|
|
252
|
+
maximum: float | None,
|
|
253
|
+
) -> None:
|
|
254
|
+
if (minimum is not None and value < minimum) or (
|
|
255
|
+
maximum is not None and value > maximum
|
|
256
|
+
):
|
|
257
|
+
raise record.error(
|
|
258
|
+
f"{value} out of range [{minimum if minimum is not None else '-inf'}, "
|
|
259
|
+
f"{maximum if maximum is not None else 'inf'}]",
|
|
260
|
+
field,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class MissingField(ParseError):
|
|
265
|
+
"""A field missing from one record (see :func:`read_records`)."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, field: str, index: int | None) -> None:
|
|
268
|
+
super().__init__("missing field", field=field, index=index)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def read_records[V](
|
|
272
|
+
records: Iterable[Record], build: Callable[[Record], V]
|
|
273
|
+
) -> tuple[list[tuple[int, V]], list[SkippedRecord]]:
|
|
274
|
+
"""Build one value per record, skipping the records that cannot be read.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The values with the index of their record, and the records skipped.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
UnexpectedContent: If no record can be read because the same field
|
|
281
|
+
is missing from all of them: not this product, or a format no
|
|
282
|
+
parser knows.
|
|
283
|
+
ParseError: If no record can be read for any other reason (the
|
|
284
|
+
error of the first record).
|
|
285
|
+
"""
|
|
286
|
+
good: list[tuple[int, V]] = []
|
|
287
|
+
failures: list[tuple[int, ParseError]] = []
|
|
288
|
+
for record in records:
|
|
289
|
+
try:
|
|
290
|
+
good.append((record.index, build(record)))
|
|
291
|
+
except ParseError as exc:
|
|
292
|
+
failures.append((record.index, exc))
|
|
293
|
+
if not good and failures:
|
|
294
|
+
fields = {exc.field for _, exc in failures if isinstance(exc, MissingField)}
|
|
295
|
+
if len(fields) == 1 and all(isinstance(e, MissingField) for _, e in failures):
|
|
296
|
+
raise UnexpectedContent(
|
|
297
|
+
f"field {fields.pop()!r} missing from every record:"
|
|
298
|
+
" not this product, or a format no parser knows"
|
|
299
|
+
)
|
|
300
|
+
raise failures[0][1]
|
|
301
|
+
skipped = [
|
|
302
|
+
SkippedRecord(index=index, field=exc.field, reason=exc.reason)
|
|
303
|
+
for index, exc in failures
|
|
304
|
+
]
|
|
305
|
+
return good, skipped
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def json_records(raw: str | bytes) -> list[Record]:
|
|
309
|
+
"""Read a JSON product made of records, in either known shape.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
One :class:`Record` per record, with ``index`` its position in the
|
|
313
|
+
JSON array (the header row of the old shape is position 0).
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
SourceNotReady: If the content is empty, truncated or has no records.
|
|
317
|
+
UnexpectedContent: If the content is not an array of objects nor an
|
|
318
|
+
array of arrays with a header row.
|
|
319
|
+
ParseError: If a record does not match the shape of the first one.
|
|
320
|
+
"""
|
|
321
|
+
document = load_json(raw)
|
|
322
|
+
if not isinstance(document, list):
|
|
323
|
+
raise UnexpectedContent(
|
|
324
|
+
f"expected a JSON array of records, got {type(document).__name__}"
|
|
325
|
+
)
|
|
326
|
+
if not document:
|
|
327
|
+
raise SourceNotReady("no records")
|
|
328
|
+
first = document[0]
|
|
329
|
+
if isinstance(first, dict):
|
|
330
|
+
records = []
|
|
331
|
+
for index, item in enumerate(document):
|
|
332
|
+
if not isinstance(item, dict):
|
|
333
|
+
raise ParseError("expected an object", index=index)
|
|
334
|
+
records.append(Record(index, item))
|
|
335
|
+
return records
|
|
336
|
+
if isinstance(first, list):
|
|
337
|
+
if not first or not all(isinstance(name, str) for name in first):
|
|
338
|
+
raise UnexpectedContent("array of arrays without a header row of names")
|
|
339
|
+
if len(document) == 1:
|
|
340
|
+
raise SourceNotReady("no records after the header row")
|
|
341
|
+
rows = []
|
|
342
|
+
for index, item in enumerate(document[1:], start=1):
|
|
343
|
+
if not isinstance(item, list):
|
|
344
|
+
raise ParseError("expected an array", index=index)
|
|
345
|
+
if len(item) != len(first):
|
|
346
|
+
raise ParseError(
|
|
347
|
+
f"{len(item)} values for {len(first)} columns", index=index
|
|
348
|
+
)
|
|
349
|
+
rows.append(Record(index, dict(zip(first, item, strict=True))))
|
|
350
|
+
return rows
|
|
351
|
+
raise UnexpectedContent(
|
|
352
|
+
f"expected objects or arrays in the JSON array, got {type(first).__name__}"
|
|
353
|
+
)
|
swpc_kit/parse/_text.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Reader shared by the SWPC text products: header, lines and errors.
|
|
2
|
+
|
|
3
|
+
A text product starts with a header::
|
|
4
|
+
|
|
5
|
+
:Product: 3-Day Forecast
|
|
6
|
+
:Issued: 2026 Oct 15 1230 UTC
|
|
7
|
+
# Prepared by the U.S. Dept. of Commerce, NOAA, ...
|
|
8
|
+
|
|
9
|
+
:func:`open_text_product` checks the header and returns a
|
|
10
|
+
:class:`TextReader` placed after the ``#`` comments. The reader walks the
|
|
11
|
+
remaining lines and classifies what goes wrong:
|
|
12
|
+
|
|
13
|
+
- the content ends before an expected element: :class:`SourceNotReady` (SWPC
|
|
14
|
+
served the file while rewriting it);
|
|
15
|
+
- an element is there but not as expected: :class:`ParseError` with its
|
|
16
|
+
1-based line number;
|
|
17
|
+
- except when that element is on the last line and the content does not end
|
|
18
|
+
with a line break: the line was probably cut, so :class:`SourceNotReady`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from bisect import bisect_right
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import UTC, date, datetime
|
|
26
|
+
|
|
27
|
+
from swpc_kit.errors import ParseError, SourceNotReady, SwpcError, UnexpectedContent
|
|
28
|
+
from swpc_kit.parse._common import decode
|
|
29
|
+
|
|
30
|
+
MONTHS = {
|
|
31
|
+
name: number
|
|
32
|
+
for number, name in enumerate(
|
|
33
|
+
(
|
|
34
|
+
"Jan",
|
|
35
|
+
"Feb",
|
|
36
|
+
"Mar",
|
|
37
|
+
"Apr",
|
|
38
|
+
"May",
|
|
39
|
+
"Jun",
|
|
40
|
+
"Jul",
|
|
41
|
+
"Aug",
|
|
42
|
+
"Sep",
|
|
43
|
+
"Oct",
|
|
44
|
+
"Nov",
|
|
45
|
+
"Dec",
|
|
46
|
+
),
|
|
47
|
+
start=1,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
"""English month abbreviations as SWPC writes them, to month numbers."""
|
|
51
|
+
|
|
52
|
+
_PRODUCT = re.compile(r":Product:\s*(.*)")
|
|
53
|
+
_ISSUED = re.compile(
|
|
54
|
+
r":Issued:\s*(\d{4})\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2})(\d{2})\s+UTC"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Line:
|
|
60
|
+
"""One line of a text product.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
number: 1-based line number.
|
|
64
|
+
text: The line without leading and trailing whitespace.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
number: int
|
|
68
|
+
text: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class Unwrapped:
|
|
73
|
+
"""Free text with its line wrapping undone, mapped back to its lines.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
text: Wrapped lines joined with a space, paragraphs with a blank line.
|
|
77
|
+
starts: Offset in ``text`` where each source line starts.
|
|
78
|
+
numbers: Line number of each source line, aligned with ``starts``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
text: str
|
|
82
|
+
starts: tuple[int, ...]
|
|
83
|
+
numbers: tuple[int, ...]
|
|
84
|
+
|
|
85
|
+
def line_at(self, offset: int) -> int:
|
|
86
|
+
"""Return the line number of the character at ``offset``."""
|
|
87
|
+
index = max(bisect_right(self.starts, offset) - 1, 0)
|
|
88
|
+
return self.numbers[index]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def unwrap(lines: Sequence[Line]) -> Unwrapped:
|
|
92
|
+
"""Join wrapped lines into text, keeping blank lines as paragraph breaks."""
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
starts: list[int] = []
|
|
95
|
+
numbers: list[int] = []
|
|
96
|
+
length = 0
|
|
97
|
+
pending_break = False
|
|
98
|
+
for line in lines:
|
|
99
|
+
if not line.text:
|
|
100
|
+
pending_break = bool(parts)
|
|
101
|
+
continue
|
|
102
|
+
if parts:
|
|
103
|
+
separator = "\n\n" if pending_break else " "
|
|
104
|
+
parts.append(separator)
|
|
105
|
+
length += len(separator)
|
|
106
|
+
pending_break = False
|
|
107
|
+
starts.append(length)
|
|
108
|
+
numbers.append(line.number)
|
|
109
|
+
parts.append(line.text)
|
|
110
|
+
length += len(line.text)
|
|
111
|
+
return Unwrapped("".join(parts), tuple(starts), tuple(numbers))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def nearest_date(month: int, day: int, near: date) -> date:
|
|
115
|
+
"""Place a month and day without a year in the year closest to ``near``.
|
|
116
|
+
|
|
117
|
+
A forecast issued on 31 December for ``Jan 01`` gets the next year; an
|
|
118
|
+
observation of ``31 Dec`` issued on 1 January gets the previous one.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If no year has that day (such as 31 April).
|
|
122
|
+
"""
|
|
123
|
+
candidates = []
|
|
124
|
+
for year in (near.year - 1, near.year, near.year + 1):
|
|
125
|
+
try:
|
|
126
|
+
candidates.append(date(year, month, day))
|
|
127
|
+
except ValueError:
|
|
128
|
+
continue
|
|
129
|
+
if not candidates:
|
|
130
|
+
raise ValueError(f"no such day: month {month}, day {day}")
|
|
131
|
+
return min(candidates, key=lambda d: abs(d - near))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TextReader:
|
|
135
|
+
"""Walks the lines of a text product, raising the right error.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
text: The decoded content.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
__slots__ = ("_complete", "_lines", "_pos")
|
|
142
|
+
|
|
143
|
+
def __init__(self, text: str) -> None:
|
|
144
|
+
self._lines = text.splitlines()
|
|
145
|
+
self._complete = text.endswith(("\n", "\r"))
|
|
146
|
+
self._pos = 0
|
|
147
|
+
|
|
148
|
+
def is_cut(self, line: int) -> bool:
|
|
149
|
+
"""Whether ``line`` is the last line and has no line break after it."""
|
|
150
|
+
return line >= len(self._lines) and not self._complete
|
|
151
|
+
|
|
152
|
+
def error(self, message: str, line: int, field: str | None = None) -> SwpcError:
|
|
153
|
+
"""Return the error for an unexpected ``line``.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
:class:`SourceNotReady` if the line looks cut (see :meth:`is_cut`),
|
|
157
|
+
otherwise :class:`ParseError` naming ``field`` and ``line``.
|
|
158
|
+
"""
|
|
159
|
+
if self.is_cut(line):
|
|
160
|
+
return SourceNotReady(f"content ends inside line {line}: {message}")
|
|
161
|
+
return ParseError(message, field=field, line=line)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def ended(what: str) -> SourceNotReady:
|
|
165
|
+
"""Return the error for content that ends before ``what``."""
|
|
166
|
+
return SourceNotReady(f"content ends before {what}")
|
|
167
|
+
|
|
168
|
+
def _line(self) -> Line:
|
|
169
|
+
return Line(self._pos + 1, self._lines[self._pos].strip())
|
|
170
|
+
|
|
171
|
+
def peek(self) -> Line | None:
|
|
172
|
+
"""Return the next non-blank line without consuming it, or ``None``."""
|
|
173
|
+
while self._pos < len(self._lines) and not self._lines[self._pos].strip():
|
|
174
|
+
self._pos += 1
|
|
175
|
+
if self._pos >= len(self._lines):
|
|
176
|
+
return None
|
|
177
|
+
return self._line()
|
|
178
|
+
|
|
179
|
+
def next(self, what: str) -> Line:
|
|
180
|
+
"""Consume and return the next non-blank line.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
SourceNotReady: If the content ends first.
|
|
184
|
+
"""
|
|
185
|
+
line = self.peek()
|
|
186
|
+
if line is None:
|
|
187
|
+
raise self.ended(what)
|
|
188
|
+
self._pos += 1
|
|
189
|
+
return line
|
|
190
|
+
|
|
191
|
+
def expect(
|
|
192
|
+
self, pattern: re.Pattern[str], what: str, field: str | None = None
|
|
193
|
+
) -> tuple[Line, re.Match[str]]:
|
|
194
|
+
"""Consume the next non-blank line, which must match ``pattern`` fully.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
SourceNotReady: If the content ends first, or the line looks cut.
|
|
198
|
+
ParseError: If the line does not match.
|
|
199
|
+
"""
|
|
200
|
+
line = self.next(what)
|
|
201
|
+
match = pattern.fullmatch(line.text)
|
|
202
|
+
if match is None:
|
|
203
|
+
raise self.error(f"expected {what}, got {line.text!r}", line.number, field)
|
|
204
|
+
return line, match
|
|
205
|
+
|
|
206
|
+
def skip_comments(self) -> None:
|
|
207
|
+
"""Skip blank lines and ``#`` comment lines."""
|
|
208
|
+
while self._pos < len(self._lines):
|
|
209
|
+
text = self._lines[self._pos].strip()
|
|
210
|
+
if text and not text.startswith("#"):
|
|
211
|
+
return
|
|
212
|
+
self._pos += 1
|
|
213
|
+
|
|
214
|
+
def take_until(
|
|
215
|
+
self,
|
|
216
|
+
stop: re.Pattern[str],
|
|
217
|
+
what: str,
|
|
218
|
+
*,
|
|
219
|
+
guard: re.Pattern[str] | None = None,
|
|
220
|
+
field: str | None = None,
|
|
221
|
+
) -> list[Line]:
|
|
222
|
+
"""Consume lines, blank ones included, up to a line matching ``stop``.
|
|
223
|
+
|
|
224
|
+
The ``stop`` line is left for the next call.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
SourceNotReady: If the content ends first.
|
|
228
|
+
ParseError: If a line matching ``guard`` comes first.
|
|
229
|
+
"""
|
|
230
|
+
lines: list[Line] = []
|
|
231
|
+
while self._pos < len(self._lines):
|
|
232
|
+
line = self._line()
|
|
233
|
+
if stop.fullmatch(line.text):
|
|
234
|
+
return lines
|
|
235
|
+
if guard is not None and guard.fullmatch(line.text):
|
|
236
|
+
raise self.error(
|
|
237
|
+
f"expected {what} before {line.text!r}", line.number, field
|
|
238
|
+
)
|
|
239
|
+
lines.append(line)
|
|
240
|
+
self._pos += 1
|
|
241
|
+
raise self.ended(what)
|
|
242
|
+
|
|
243
|
+
def take_rest(self) -> list[Line]:
|
|
244
|
+
"""Consume and return every remaining line, blank ones included."""
|
|
245
|
+
rest = [
|
|
246
|
+
Line(n + 1, self._lines[n].strip())
|
|
247
|
+
for n in range(self._pos, len(self._lines))
|
|
248
|
+
]
|
|
249
|
+
self._pos = len(self._lines)
|
|
250
|
+
return rest
|
|
251
|
+
|
|
252
|
+
def finish(self, after: str) -> None:
|
|
253
|
+
"""Check that only blank lines remain.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ParseError: If there is more content after ``after``.
|
|
257
|
+
"""
|
|
258
|
+
line = self.peek()
|
|
259
|
+
if line is not None:
|
|
260
|
+
raise self.error(
|
|
261
|
+
f"unexpected content after {after}: {line.text!r}", line.number
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def date(
|
|
265
|
+
self, month: str, day: str, near: date, line: int, field: str | None
|
|
266
|
+
) -> date:
|
|
267
|
+
"""Return the date of a month abbreviation and day, near ``near``.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ParseError: If the month or day is not valid.
|
|
271
|
+
"""
|
|
272
|
+
number = MONTHS.get(month.title())
|
|
273
|
+
if number is None:
|
|
274
|
+
raise self.error(f"unknown month {month!r}", line, field)
|
|
275
|
+
try:
|
|
276
|
+
return nearest_date(number, int(day), near)
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
raise self.error(str(exc), line, field) from None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def open_text_product(
|
|
282
|
+
raw: str | bytes, products: Sequence[str]
|
|
283
|
+
) -> tuple[TextReader, datetime]:
|
|
284
|
+
"""Check the header of a text product and return a reader after it.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
raw: The content.
|
|
288
|
+
products: Accepted values of ``:Product:``, compared ignoring case and
|
|
289
|
+
repeated spaces.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
A reader placed after the header and its ``#`` comments, and the
|
|
293
|
+
``:Issued:`` time.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
SourceNotReady: If the content is empty or ends inside the header.
|
|
297
|
+
UnexpectedContent: If the content is HTML, not a text product, or a
|
|
298
|
+
different product.
|
|
299
|
+
ParseError: If the ``:Issued:`` line is not valid.
|
|
300
|
+
"""
|
|
301
|
+
text = decode(raw)
|
|
302
|
+
if not text.strip():
|
|
303
|
+
raise SourceNotReady("empty content")
|
|
304
|
+
if text.lstrip().startswith("<"):
|
|
305
|
+
raise UnexpectedContent("got HTML or XML instead of a text product")
|
|
306
|
+
reader = TextReader(text)
|
|
307
|
+
first = reader.next("the ':Product:' line")
|
|
308
|
+
match = _PRODUCT.fullmatch(first.text)
|
|
309
|
+
if match is None:
|
|
310
|
+
if reader.is_cut(first.number) and ":Product:".startswith(first.text):
|
|
311
|
+
raise reader.ended("the end of the ':Product:' line")
|
|
312
|
+
raise UnexpectedContent(f"expected a ':Product:' line, got {first.text[:80]!r}")
|
|
313
|
+
name = " ".join(match[1].split())
|
|
314
|
+
accepted = [" ".join(p.split()) for p in products]
|
|
315
|
+
if name.casefold() not in (p.casefold() for p in accepted):
|
|
316
|
+
if reader.is_cut(first.number) and any(
|
|
317
|
+
p.casefold().startswith(name.casefold()) for p in accepted
|
|
318
|
+
):
|
|
319
|
+
raise reader.ended("the end of the ':Product:' line")
|
|
320
|
+
raise UnexpectedContent(f"expected product {accepted[0]!r}, got {name!r}")
|
|
321
|
+
|
|
322
|
+
line, match = reader.expect(_ISSUED, "':Issued: YYYY Mon DD HHMM UTC'", "Issued")
|
|
323
|
+
year, month, day, hour, minute = match.groups()
|
|
324
|
+
number = MONTHS.get(month.title())
|
|
325
|
+
if number is None:
|
|
326
|
+
raise reader.error(f"unknown month {month!r}", line.number, "Issued")
|
|
327
|
+
try:
|
|
328
|
+
issued = datetime(
|
|
329
|
+
int(year), number, int(day), int(hour), int(minute), tzinfo=UTC
|
|
330
|
+
)
|
|
331
|
+
except ValueError as exc:
|
|
332
|
+
raise reader.error(f"not a valid time: {exc}", line.number, "Issued") from None
|
|
333
|
+
reader.skip_comments()
|
|
334
|
+
return reader, issued
|