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,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
+ )
@@ -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