meteoalarmapi 0.4.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,173 @@
|
|
|
1
|
+
"""Weather alerts for a European region from MeteoAlarm.org.
|
|
2
|
+
|
|
3
|
+
Reads the country's legacy Atom feed, keeps the entries whose area matches
|
|
4
|
+
the region (by name or EMMA_ID code) and fetches each one's CAP document.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
import xmltodict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WrongCountry(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnexpectedError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# CAP severities, least to most severe
|
|
27
|
+
SEVERITIES = ["Unknown", "Minor", "Moderate", "Severe", "Extreme"]
|
|
28
|
+
TIMEOUT = 10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utcnow():
|
|
32
|
+
# A function so tests can patch the clock
|
|
33
|
+
return datetime.now(timezone.utc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Meteoalert:
|
|
37
|
+
|
|
38
|
+
endpoint = "https://feeds.meteoalarm.org/feeds/meteoalarm-legacy-atom-{0}"
|
|
39
|
+
|
|
40
|
+
def __init__(self, country, province, language="en-GB"):
|
|
41
|
+
self.country = country.lower()
|
|
42
|
+
self.province = province
|
|
43
|
+
self.language = language
|
|
44
|
+
|
|
45
|
+
def get_alert(self):
|
|
46
|
+
"""The most severe alert for the province, or {} when there is none.
|
|
47
|
+
|
|
48
|
+
See get_alerts() for the order.
|
|
49
|
+
"""
|
|
50
|
+
alerts = self.get_alerts()
|
|
51
|
+
return alerts[0] if alerts else {}
|
|
52
|
+
|
|
53
|
+
def get_alerts(self, include_expired=True):
|
|
54
|
+
"""Every alert for the province, most severe first.
|
|
55
|
+
|
|
56
|
+
Unexpired alerts come first, then the highest awareness level (or CAP
|
|
57
|
+
severity when there is none). Equally ranked alerts keep feed order.
|
|
58
|
+
|
|
59
|
+
With include_expired=False, expired alerts are left out. Future
|
|
60
|
+
alerts (not started yet) are kept, so upcoming warnings still show.
|
|
61
|
+
"""
|
|
62
|
+
feed = xmltodict.parse(self._fetch(self.endpoint.format(self.country)))
|
|
63
|
+
entries = (feed.get("feed") or {}).get("entry", [])
|
|
64
|
+
if not isinstance(entries, list):
|
|
65
|
+
entries = [entries]
|
|
66
|
+
|
|
67
|
+
alerts = []
|
|
68
|
+
# The feed has an entry per language and area of an alert, all
|
|
69
|
+
# pointing at the same CAP document, so fetch each one once.
|
|
70
|
+
seen = set()
|
|
71
|
+
for entry in entries:
|
|
72
|
+
if not self.is_location_match(entry, self.province):
|
|
73
|
+
continue
|
|
74
|
+
links = entry.get("link", [])
|
|
75
|
+
if not isinstance(links, list):
|
|
76
|
+
links = [links]
|
|
77
|
+
cap_url = next((link.get("@href") for link in links
|
|
78
|
+
if link.get("@type") == "application/cap+xml"), None)
|
|
79
|
+
if cap_url and cap_url not in seen:
|
|
80
|
+
seen.add(cap_url)
|
|
81
|
+
text = self._fetch(cap_url, missing_ok=True)
|
|
82
|
+
# A warning withdrawn between reading the feed and fetching its
|
|
83
|
+
# document is gone, not a sign of a wrong country, so skip it.
|
|
84
|
+
if text is not None:
|
|
85
|
+
alerts.append(self._parse_cap(text))
|
|
86
|
+
|
|
87
|
+
if not include_expired:
|
|
88
|
+
# Some feeds keep warnings for a while after they end
|
|
89
|
+
alerts = [a for a in alerts if not self.is_expired(a.get("expires"))]
|
|
90
|
+
|
|
91
|
+
# sorted() is stable, so equally ranked alerts keep feed order
|
|
92
|
+
return sorted(alerts, key=self.alert_rank, reverse=True)
|
|
93
|
+
|
|
94
|
+
def _parse_cap(self, text):
|
|
95
|
+
"""The CAP <info> in the requested language (or the first one) as a dict,
|
|
96
|
+
plus the message fields needed to track an alert across updates"""
|
|
97
|
+
alert = xmltodict.parse(text).get("alert", {})
|
|
98
|
+
infos = alert.get("info", [])
|
|
99
|
+
if not isinstance(infos, list):
|
|
100
|
+
infos = [infos]
|
|
101
|
+
if not infos:
|
|
102
|
+
return {}
|
|
103
|
+
info = next((i for i in infos if self.language in (i.get("language") or "")),
|
|
104
|
+
infos[0])
|
|
105
|
+
|
|
106
|
+
data = {key: value for key, value in info.items() if isinstance(value, str)}
|
|
107
|
+
parameters = info.get("parameter", [])
|
|
108
|
+
if not isinstance(parameters, list):
|
|
109
|
+
parameters = [parameters]
|
|
110
|
+
for parameter in parameters:
|
|
111
|
+
data[parameter.get("valueName")] = parameter.get("value")
|
|
112
|
+
|
|
113
|
+
# Each CAP message has its own identifier; an Update or Cancel lists
|
|
114
|
+
# the messages it replaces in references
|
|
115
|
+
data["identifier"] = alert.get("identifier")
|
|
116
|
+
data["message_type"] = alert.get("msgType")
|
|
117
|
+
data["references"] = alert.get("references")
|
|
118
|
+
data["sent"] = alert.get("sent")
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
def alert_rank(self, data):
|
|
122
|
+
"""Sort key for an alert: (not expired, awareness level, severity)"""
|
|
123
|
+
level = re.match(r"\s*(\d+)", data.get("awareness_level") or "")
|
|
124
|
+
severity = data.get("severity")
|
|
125
|
+
return (
|
|
126
|
+
not self.is_expired(data.get("expires")),
|
|
127
|
+
int(level.group(1)) if level else 0,
|
|
128
|
+
SEVERITIES.index(severity) if severity in SEVERITIES else 0,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def is_expired(expires):
|
|
133
|
+
"""Whether a CAP time (2024-01-01T12:00:00+01:00) is in the past"""
|
|
134
|
+
try:
|
|
135
|
+
moment = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
136
|
+
except (AttributeError, ValueError):
|
|
137
|
+
return False
|
|
138
|
+
if moment.tzinfo is None:
|
|
139
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
140
|
+
return moment <= _utcnow()
|
|
141
|
+
|
|
142
|
+
def is_location_match(self, entry, province):
|
|
143
|
+
# Attempt to match the province first by regex on name
|
|
144
|
+
if re.search(rf"{province}", entry.get("cap:areaDesc") or "", re.IGNORECASE):
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
# then by areaCode
|
|
148
|
+
geocode = entry.get("cap:geocode")
|
|
149
|
+
return isinstance(geocode, dict) and geocode.get("value") == province
|
|
150
|
+
|
|
151
|
+
def _fetch(self, url, missing_ok=False):
|
|
152
|
+
"""The response text, or None for a 404/410 when missing_ok"""
|
|
153
|
+
try:
|
|
154
|
+
response = requests.get(url, timeout=TIMEOUT)
|
|
155
|
+
except Exception as err:
|
|
156
|
+
raise UnexpectedError(str(err)) from err
|
|
157
|
+
if missing_ok and response.status_code in (404, 410):
|
|
158
|
+
return None
|
|
159
|
+
self.check_status_code(response.status_code)
|
|
160
|
+
return response.content.decode("utf-8")
|
|
161
|
+
|
|
162
|
+
def check_status_code(self, status_code):
|
|
163
|
+
if status_code == 404:
|
|
164
|
+
# MeteoAlarm sometimes briefly returns 404 for valid countries too
|
|
165
|
+
raise WrongCountry(
|
|
166
|
+
f"MeteoAlarm has no feed for country {self.country!r} (404). Check the "
|
|
167
|
+
"name matches feeds.meteoalarm.org; if it does, MeteoAlarm may be "
|
|
168
|
+
"temporarily unavailable"
|
|
169
|
+
)
|
|
170
|
+
elif status_code >= 500:
|
|
171
|
+
raise ServerError(f"Server error - status code: {status_code}")
|
|
172
|
+
elif status_code != 200:
|
|
173
|
+
raise UnexpectedError(f"Server returned unexpected status code: {status_code}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: meteoalarmapi
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Weather alerts for European regions from MeteoAlarm.org. A maintained fork of meteoalertapi.
|
|
5
|
+
Project-URL: Source, https://github.com/mastermc0/meteoalarmapi
|
|
6
|
+
Project-URL: Issues, https://github.com/mastermc0/meteoalarmapi/issues
|
|
7
|
+
Project-URL: Original, https://github.com/rolfberkenbosch/meteoalert-api
|
|
8
|
+
Author: Rolf Berkenbosch
|
|
9
|
+
Maintainer: mastermc0
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: requests
|
|
16
|
+
Requires-Dist: xmltodict
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest; extra == 'test'
|
|
19
|
+
Requires-Dist: requests-mock; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# meteoalarmapi
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
An unofficial Python wrapper for [MeteoAlarm.org](https://meteoalarm.org) (European weather alerts), which can be used to check whether a given province in your country currently has a weather alert.
|
|
27
|
+
|
|
28
|
+
This is a maintained fork of [meteoalertapi](https://github.com/rolfberkenbosch/meteoalert-api) by Rolf Berkenbosch, which has had no releases since February 2024. It keeps the same `Meteoalert` class and alert fields. See [What changed from meteoalertapi](#what-changed-from-meteoalertapi).
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```console
|
|
33
|
+
pip install meteoalarmapi
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Code example
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from meteoalarmapi import Meteoalert
|
|
40
|
+
|
|
41
|
+
# Find your country and province on https://meteoalarm.org/ or https://feeds.meteoalarm.org/
|
|
42
|
+
# The province can be its name (matched as a regular expression) or its EMMA_ID code.
|
|
43
|
+
meteo = Meteoalert('united-kingdom', 'Highlands & Eilean Siar')
|
|
44
|
+
|
|
45
|
+
# The most severe current alert, or {} when there is none
|
|
46
|
+
print(meteo.get_alert())
|
|
47
|
+
|
|
48
|
+
# Every alert for the province, most severe first
|
|
49
|
+
for alert in meteo.get_alerts():
|
|
50
|
+
print(alert['event'], alert['severity'], alert['onset'], alert['expires'])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each alert also has the CAP message fields `identifier`, `message_type` (`Alert`, `Update` or `Cancel`), `references` and `sent`, so you can follow a warning across updates and tell when one is cancelled.
|
|
54
|
+
|
|
55
|
+
Some feeds keep warnings for a while after they end. Pass `include_expired=False` to leave expired alerts out. Future alerts, which haven't started yet, are kept:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
for alert in meteo.get_alerts(include_expired=False):
|
|
59
|
+
print(alert['event'], alert['onset'], alert['expires'])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Alerts are ranked with current ones first, then by awareness level (`awareness_level`, e.g. `3; Orange; Severe`), then by CAP severity. Alerts that rank equally keep their order in the feed.
|
|
63
|
+
|
|
64
|
+
## What changed from meteoalertapi
|
|
65
|
+
|
|
66
|
+
- The package is now imported as `meteoalarmapi` instead of `meteoalertapi`.
|
|
67
|
+
- `get_alert()` returns the most severe current alert. Before, it returned the first match in the feed, so a yellow warning could hide an amber one.
|
|
68
|
+
- New `get_alerts()` returns every matching alert, most severe first.
|
|
69
|
+
- `get_alerts(include_expired=False)` leaves out expired alerts, which some feeds keep for a while. Future alerts are kept.
|
|
70
|
+
- `awareness_level`, `awareness_type` and the other CAP parameters are now included for feeds with a single language, such as the UK's. Before, they were silently dropped.
|
|
71
|
+
- An alert listed several times in the feed (once per language or area) is returned and fetched only once.
|
|
72
|
+
- Each alert includes the CAP `identifier`, `message_type`, `references` and `sent`.
|
|
73
|
+
- Python 3.10 or newer is required.
|
|
74
|
+
|
|
75
|
+
## Changelog
|
|
76
|
+
|
|
77
|
+
See the [CHANGELOG](./CHANGELOG.md) file.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT. Original work copyright (c) 2018 Rolf Berkenbosch.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
meteoalarmapi/__init__.py,sha256=m5QxF1M2bJ7_e_ODEdq4etSSHFdpsyfb5gbrGhtgRBY,176
|
|
2
|
+
meteoalarmapi/meteoalert.py,sha256=GrmVryeIcZJa5lxae6B3bdSaWyKFmKus4Igs_3dcuqA,6573
|
|
3
|
+
meteoalarmapi-0.4.0.dist-info/METADATA,sha256=Hh1Ezn_v5uki2kZ0zDfy3J2W9LjyIG6Bo3HUccm1Vco,3656
|
|
4
|
+
meteoalarmapi-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
5
|
+
meteoalarmapi-0.4.0.dist-info/licenses/LICENSE,sha256=HBaBGf6hr0cOIaBAABNb1Tw8EWYSIYofOInJLKikqV0,1072
|
|
6
|
+
meteoalarmapi-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Rolf Berkenbosch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|