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,252 @@
1
+ r"""Parser for ``alerts.json``, the space weather messages of the last weeks.
2
+
3
+ The JSON is an array of ``{product_id, issue_datetime, message}`` objects.
4
+ Each ``message`` is free text: lines end with ``\r\n`` or ``\n`` (both are
5
+ mixed in one message), the first three lines are the header
6
+ (``Space Weather Message Code:``, ``Serial Number:``, ``Issue Time:``), then
7
+ come a title such as ``ALERT: ...`` and ``Name: value`` lines.
8
+
9
+ The header is mandatory: if it is missing or broken the parser raises
10
+ :class:`~swpc_kit.errors.ParseError`. Everything else is optional: a value
11
+ in an unknown form gives ``None`` in the typed attribute and stays as text
12
+ in the message fields. The message text is data, never instructions.
13
+ """
14
+
15
+ import re
16
+ from collections.abc import Sequence
17
+ from datetime import UTC, datetime
18
+
19
+ from swpc_kit.errors import UnexpectedContent
20
+ from swpc_kit.models.alerts import (
21
+ AlertMessage,
22
+ Alerts,
23
+ MessageField,
24
+ MessageKind,
25
+ NoaaScale,
26
+ ScaleCategory,
27
+ field_key,
28
+ )
29
+ from swpc_kit.models.records import SkippedRecord
30
+ from swpc_kit.parse._common import Record, load_json, read_records
31
+
32
+ ALERTS_URL = "https://services.swpc.noaa.gov/products/alerts.json"
33
+
34
+ _CODE_LABEL = "Space Weather Message Code"
35
+ _SERIAL_LABEL = "Serial Number"
36
+ _ISSUE_LABEL = "Issue Time"
37
+
38
+ _MONTHS = {
39
+ name: number
40
+ for number, name in enumerate(
41
+ [
42
+ "Jan",
43
+ "Feb",
44
+ "Mar",
45
+ "Apr",
46
+ "May",
47
+ "Jun",
48
+ "Jul",
49
+ "Aug",
50
+ "Sep",
51
+ "Oct",
52
+ "Nov",
53
+ "Dec",
54
+ ],
55
+ start=1,
56
+ )
57
+ }
58
+ _TEXT_TIME = re.compile(r"(\d{4}) ([A-Z][a-z]{2}) (\d{1,2}) (\d{2})(\d{2}) UTC")
59
+ _NEWLINE = re.compile(r"\r\n|\r|\n")
60
+ _CODE = re.compile(r"[A-Za-z0-9]+")
61
+ _SERIAL = re.compile(r"\d+")
62
+ _TITLE = re.compile(r"([A-Z]+(?: [A-Z]+)*):")
63
+ _FIELD = re.compile(r"([A-Za-z][A-Za-z0-9 ()/-]*?)\s*:(.*)")
64
+ _DATE_KEY = re.compile(r"[A-Z][a-z]{2} \d{1,2}")
65
+ _SCALE = re.compile(r"([GSR])([1-5])(?:\s*-\s*(.*))?")
66
+
67
+
68
+ def _text_time(text: str) -> datetime:
69
+ """Parse a time written like ``2026 Oct 13 2058 UTC``.
70
+
71
+ Raises:
72
+ ValueError: If the text is not such a time.
73
+ """
74
+ match = _TEXT_TIME.fullmatch(text)
75
+ if match is None or match.group(2) not in _MONTHS:
76
+ raise ValueError(f"expected a time like '2026 Oct 13 2058 UTC', got {text!r}")
77
+ year, month, day, hour, minute = match.groups()
78
+ try:
79
+ return datetime(
80
+ int(year), _MONTHS[month], int(day), int(hour), int(minute), tzinfo=UTC
81
+ )
82
+ except ValueError as exc:
83
+ raise ValueError(f"not a valid time: {text!r} ({exc})") from None
84
+
85
+
86
+ def _header(record: Record, lines: Sequence[str], label: str) -> tuple[int, str]:
87
+ """Return the position and value of the first line ``label: value``."""
88
+ prefix = label.lower() + ":"
89
+ for number, line in enumerate(lines):
90
+ if line[: len(prefix)].lower() == prefix:
91
+ return number, line[len(prefix) :].strip()
92
+ raise record.error("missing header line", f"message.{label}")
93
+
94
+
95
+ def _fields(lines: Sequence[str]) -> tuple[MessageField, ...]:
96
+ """Return the ``Name: value`` lines.
97
+
98
+ Day labels of watches (``Oct 12: None (Below G1)``) and lines starting
99
+ with a URL are not fields.
100
+ """
101
+ fields = []
102
+ for line in lines:
103
+ match = _FIELD.fullmatch(line.strip())
104
+ if match is None:
105
+ continue
106
+ name, value = match.group(1).strip(), match.group(2).strip()
107
+ if _DATE_KEY.fullmatch(name) or value.startswith("//"):
108
+ continue
109
+ fields.append(MessageField(name, value))
110
+ return tuple(fields)
111
+
112
+
113
+ def _values(fields: Sequence[MessageField], *names: str) -> list[str]:
114
+ keys = {field_key(n) for n in names}
115
+ return [f.value for f in fields if field_key(f.name) in keys]
116
+
117
+
118
+ def _optional_time(fields: Sequence[MessageField], *names: str) -> datetime | None:
119
+ """Return the first field found, by order of ``names``, as a time.
120
+
121
+ Returns ``None`` when no such field exists or its value has another form.
122
+ """
123
+ for name in names:
124
+ values = _values(fields, name)
125
+ if values:
126
+ try:
127
+ return _text_time(values[0])
128
+ except ValueError:
129
+ return None
130
+ return None
131
+
132
+
133
+ def _optional_serial(fields: Sequence[MessageField], name: str) -> int | None:
134
+ """Return the named field as a serial number, or ``None``."""
135
+ values = _values(fields, name)
136
+ if not values or not _SERIAL.fullmatch(values[0]):
137
+ return None
138
+ return int(values[0])
139
+
140
+
141
+ def _scale(fields: Sequence[MessageField]) -> NoaaScale | None:
142
+ for value in _values(fields, "NOAA Scale"):
143
+ match = _SCALE.fullmatch(value)
144
+ if match is not None:
145
+ label = (match.group(3) or "").strip() or None
146
+ return NoaaScale(ScaleCategory(match.group(1)), int(match.group(2)), label)
147
+ return None
148
+
149
+
150
+ def _kind(title: str) -> MessageKind | None:
151
+ match = _TITLE.match(title)
152
+ if match is None:
153
+ return None
154
+ try:
155
+ return MessageKind(match.group(1))
156
+ except ValueError:
157
+ return None
158
+
159
+
160
+ def _message(record: Record) -> AlertMessage:
161
+ product_id = record.get_str("product_id").strip()
162
+ if not product_id:
163
+ raise record.error("empty product_id", "product_id")
164
+ issued = record.get_time("issue_datetime")
165
+ text = record.get_str("message")
166
+ lines = _NEWLINE.split(text)
167
+
168
+ end, code = _header(record, lines, _CODE_LABEL)
169
+ if not _CODE.fullmatch(code):
170
+ raise record.error(f"expected a code, got {code!r}", f"message.{_CODE_LABEL}")
171
+ position, serial = _header(record, lines, _SERIAL_LABEL)
172
+ end = max(end, position)
173
+ if not _SERIAL.fullmatch(serial):
174
+ raise record.error(
175
+ f"expected a serial number, got {serial!r}", f"message.{_SERIAL_LABEL}"
176
+ )
177
+ position, issue = _header(record, lines, _ISSUE_LABEL)
178
+ end = max(end, position)
179
+ try:
180
+ issue_time = _text_time(issue)
181
+ except ValueError as exc:
182
+ raise record.error(str(exc), f"message.{_ISSUE_LABEL}") from None
183
+
184
+ body = lines[end + 1 :]
185
+ start = next((n for n, line in enumerate(body) if line.strip()), len(body))
186
+ title = body[start].strip() if start < len(body) else ""
187
+ fields = _fields(body[start + 1 :])
188
+
189
+ return AlertMessage(
190
+ product_id=product_id,
191
+ code=code,
192
+ serial_number=int(serial),
193
+ issued=issued,
194
+ issue_time=issue_time,
195
+ kind=_kind(title),
196
+ title=title,
197
+ valid_from=_optional_time(fields, "Valid From"),
198
+ valid_to=_optional_time(fields, "Now Valid Until", "Valid To"),
199
+ threshold_reached=_optional_time(fields, "Threshold Reached"),
200
+ begin_time=_optional_time(fields, "Begin Time"),
201
+ maximum_time=_optional_time(fields, "Maximum Time", "Max time"),
202
+ end_time=_optional_time(fields, "End Time"),
203
+ observed=_optional_time(fields, "Observed"),
204
+ extension_to_serial=_optional_serial(fields, "Extension to Serial Number"),
205
+ continuation_of_serial=_optional_serial(
206
+ fields, "Continuation of Serial Number"
207
+ ),
208
+ cancel_serial=_optional_serial(fields, "Cancel Serial Number"),
209
+ noaa_scale=_scale(fields),
210
+ fields=fields,
211
+ text=text,
212
+ )
213
+
214
+
215
+ def parse_alerts(raw: str | bytes, *, source_url: str = ALERTS_URL) -> Alerts:
216
+ """Parse ``alerts.json`` (space weather messages of the last weeks).
217
+
218
+ Args:
219
+ raw: The content. An empty array gives a model without messages.
220
+ source_url: Where the content came from.
221
+
222
+ Raises:
223
+ SourceNotReady: If the content is empty or truncated.
224
+ UnexpectedContent: If the content is not an array of objects.
225
+ ParseError: If a record or the header of a message is missing or
226
+ inconsistent; the error names the field and the record.
227
+ """
228
+ document = load_json(raw)
229
+ if not isinstance(document, list):
230
+ raise UnexpectedContent(
231
+ f"expected a JSON array of messages, got {type(document).__name__}"
232
+ )
233
+ if document and not isinstance(document[0], dict):
234
+ raise UnexpectedContent(
235
+ f"expected objects in the JSON array, got {type(document[0]).__name__}"
236
+ )
237
+ not_objects = [
238
+ SkippedRecord(index=index, field=None, reason="expected an object")
239
+ for index, item in enumerate(document)
240
+ if not isinstance(item, dict)
241
+ ]
242
+ records = [
243
+ Record(index, item)
244
+ for index, item in enumerate(document)
245
+ if isinstance(item, dict)
246
+ ]
247
+ read, skipped = read_records(records, _message)
248
+ return Alerts(
249
+ messages=tuple(message for _, message in read),
250
+ source_url=source_url,
251
+ skipped=tuple(sorted([*skipped, *not_objects], key=lambda s: s.index)),
252
+ )