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,8 @@
1
+ from .meteoalert import (
2
+ Meteoalert,
3
+ ServerError,
4
+ UnexpectedError,
5
+ WrongCountry,
6
+ )
7
+
8
+ __all__ = ["Meteoalert", "ServerError", "UnexpectedError", "WrongCountry"]
@@ -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
+ ![PyPI](https://img.shields.io/pypi/v/meteoalarmapi.svg?style=for-the-badge)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.