aiodpc 0.1.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.
- aiodpc/__init__.py +62 -0
- aiodpc/client.py +351 -0
- aiodpc/const.py +42 -0
- aiodpc/discovery.py +87 -0
- aiodpc/exceptions.py +23 -0
- aiodpc/geo.py +76 -0
- aiodpc/models.py +219 -0
- aiodpc/parsers.py +417 -0
- aiodpc/py.typed +0 -0
- aiodpc/urls.py +62 -0
- aiodpc-0.1.0.dist-info/METADATA +201 -0
- aiodpc-0.1.0.dist-info/RECORD +14 -0
- aiodpc-0.1.0.dist-info/WHEEL +4 -0
- aiodpc-0.1.0.dist-info/licenses/LICENSE +22 -0
aiodpc/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Async client for the Italian Civil Protection Department (DPC) bulletins.
|
|
2
|
+
|
|
3
|
+
It reads the national "Bollettino di criticità idrogeologica e idraulica"
|
|
4
|
+
(alert levels per risk) and "Bollettino di vigilanza meteorologica" (weather
|
|
5
|
+
vigilance and phenomena) published by DPC as open data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import DpcClient
|
|
9
|
+
from .const import (
|
|
10
|
+
ATTRIBUTION,
|
|
11
|
+
CRITICALITY_PAGE_URL,
|
|
12
|
+
DEFAULT_PHENOMENA_RADIUS_KM,
|
|
13
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
14
|
+
ROME_TZ,
|
|
15
|
+
VIGILANCE_PAGE_URL,
|
|
16
|
+
)
|
|
17
|
+
from .discovery import BulletinIdSource, MapPageIdSource, TextFetcher
|
|
18
|
+
from .exceptions import DpcConnectionError, DpcError, DpcLocationError, DpcParseError
|
|
19
|
+
from .models import (
|
|
20
|
+
AlertLevel,
|
|
21
|
+
Assessment,
|
|
22
|
+
BulletinKind,
|
|
23
|
+
CriticalityBulletin,
|
|
24
|
+
CriticalityDay,
|
|
25
|
+
Phenomenon,
|
|
26
|
+
PhenomenonCategory,
|
|
27
|
+
Risk,
|
|
28
|
+
VigilanceBulletin,
|
|
29
|
+
VigilanceDay,
|
|
30
|
+
rome_date,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ATTRIBUTION",
|
|
37
|
+
"CRITICALITY_PAGE_URL",
|
|
38
|
+
"DEFAULT_PHENOMENA_RADIUS_KM",
|
|
39
|
+
"DEFAULT_REQUEST_TIMEOUT",
|
|
40
|
+
"ROME_TZ",
|
|
41
|
+
"VIGILANCE_PAGE_URL",
|
|
42
|
+
"AlertLevel",
|
|
43
|
+
"Assessment",
|
|
44
|
+
"BulletinIdSource",
|
|
45
|
+
"BulletinKind",
|
|
46
|
+
"CriticalityBulletin",
|
|
47
|
+
"CriticalityDay",
|
|
48
|
+
"DpcClient",
|
|
49
|
+
"DpcConnectionError",
|
|
50
|
+
"DpcError",
|
|
51
|
+
"DpcLocationError",
|
|
52
|
+
"DpcParseError",
|
|
53
|
+
"MapPageIdSource",
|
|
54
|
+
"Phenomenon",
|
|
55
|
+
"PhenomenonCategory",
|
|
56
|
+
"Risk",
|
|
57
|
+
"TextFetcher",
|
|
58
|
+
"VigilanceBulletin",
|
|
59
|
+
"VigilanceDay",
|
|
60
|
+
"__version__",
|
|
61
|
+
"rome_date",
|
|
62
|
+
]
|
aiodpc/client.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Async client of the DPC bulletins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Final, Self
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
|
|
14
|
+
from .const import DEFAULT_PHENOMENA_RADIUS_KM, DEFAULT_REQUEST_TIMEOUT, ROME_TZ
|
|
15
|
+
from .discovery import BulletinIdSource, MapPageIdSource
|
|
16
|
+
from .exceptions import DpcConnectionError, DpcError, DpcParseError
|
|
17
|
+
from .models import (
|
|
18
|
+
BulletinKind,
|
|
19
|
+
CriticalityBulletin,
|
|
20
|
+
CriticalityDay,
|
|
21
|
+
VigilanceBulletin,
|
|
22
|
+
VigilanceDay,
|
|
23
|
+
)
|
|
24
|
+
from .parsers import (
|
|
25
|
+
day_expires,
|
|
26
|
+
location_in_zones,
|
|
27
|
+
parse_criticality_day,
|
|
28
|
+
parse_criticality_id,
|
|
29
|
+
parse_phenomena,
|
|
30
|
+
parse_vigilance_day,
|
|
31
|
+
parse_vigilance_id,
|
|
32
|
+
)
|
|
33
|
+
from .urls import (
|
|
34
|
+
CRITICALITY_DAYS,
|
|
35
|
+
VIGILANCE_DAYS,
|
|
36
|
+
criticality_geojson_url,
|
|
37
|
+
phenomena_geojson_url,
|
|
38
|
+
vigilance_geojson_url,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Coroutine, Iterable
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_MAX_LATITUDE: Final = 90.0
|
|
47
|
+
_MAX_LONGITUDE: Final = 180.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DpcClient:
|
|
51
|
+
"""Async client of the DPC criticality and weather vigilance bulletins.
|
|
52
|
+
|
|
53
|
+
Pass the ``aiohttp.ClientSession`` of the application (it is never closed
|
|
54
|
+
by the client), or use the client as an async context manager to let it
|
|
55
|
+
manage its own session::
|
|
56
|
+
|
|
57
|
+
async with DpcClient() as client:
|
|
58
|
+
bulletin = await client.get_criticality(41.9, 12.5)
|
|
59
|
+
|
|
60
|
+
The bulletin files are large (a few MB per day): to poll, compare
|
|
61
|
+
:meth:`latest_bulletin_id` with the ID of the bulletin already fetched and
|
|
62
|
+
fetch the bulletin again only when it changed.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
session: aiohttp.ClientSession | None = None,
|
|
68
|
+
*,
|
|
69
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
70
|
+
id_source: BulletinIdSource | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Initialize the client.
|
|
73
|
+
|
|
74
|
+
``request_timeout`` applies to each HTTP request, in seconds.
|
|
75
|
+
``id_source`` finds the latest bulletin IDs (default: the DPC map pages).
|
|
76
|
+
"""
|
|
77
|
+
self._session = session
|
|
78
|
+
self._close_session = False
|
|
79
|
+
self._request_timeout = request_timeout
|
|
80
|
+
self._id_source: BulletinIdSource = id_source or MapPageIdSource()
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> Self:
|
|
83
|
+
"""Enter the async context."""
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
87
|
+
"""Exit the async context, closing the session owned by the client."""
|
|
88
|
+
await self.close()
|
|
89
|
+
|
|
90
|
+
async def close(self) -> None:
|
|
91
|
+
"""Close the session if the client created it."""
|
|
92
|
+
if self._session is not None and self._close_session:
|
|
93
|
+
await self._session.close()
|
|
94
|
+
self._session = None
|
|
95
|
+
self._close_session = False
|
|
96
|
+
|
|
97
|
+
async def latest_bulletin_id(self, kind: BulletinKind) -> str:
|
|
98
|
+
"""Return the ID of the latest published bulletin of a kind.
|
|
99
|
+
|
|
100
|
+
Raises :class:`DpcConnectionError` or :class:`DpcParseError`.
|
|
101
|
+
"""
|
|
102
|
+
kind = BulletinKind(kind)
|
|
103
|
+
bulletin_id = await self._id_source.latest_id(kind, self._fetch_text)
|
|
104
|
+
try:
|
|
105
|
+
if kind is BulletinKind.CRITICALITY:
|
|
106
|
+
parse_criticality_id(bulletin_id)
|
|
107
|
+
else:
|
|
108
|
+
parse_vigilance_id(bulletin_id)
|
|
109
|
+
except ValueError as err:
|
|
110
|
+
msg = f"Invalid {kind} bulletin ID from the ID source: {bulletin_id!r}"
|
|
111
|
+
raise DpcParseError(msg) from err
|
|
112
|
+
_LOGGER.debug("Latest %s bulletin: %s", kind, bulletin_id)
|
|
113
|
+
return bulletin_id
|
|
114
|
+
|
|
115
|
+
async def get_criticality(
|
|
116
|
+
self,
|
|
117
|
+
latitude: float,
|
|
118
|
+
longitude: float,
|
|
119
|
+
*,
|
|
120
|
+
municipality: str | None = None,
|
|
121
|
+
bulletin_id: str | None = None,
|
|
122
|
+
skip_expired: bool = True,
|
|
123
|
+
now: dt.datetime | None = None,
|
|
124
|
+
) -> CriticalityBulletin:
|
|
125
|
+
"""Return the criticality bulletin for a location.
|
|
126
|
+
|
|
127
|
+
``municipality`` (optional) selects, among the zones listing it, the one
|
|
128
|
+
with the highest alert. ``bulletin_id`` defaults to the latest bulletin.
|
|
129
|
+
With ``skip_expired``, days already over at ``now`` (default: the
|
|
130
|
+
current time) are not downloaded and are missing from ``days``.
|
|
131
|
+
|
|
132
|
+
Raises :class:`DpcConnectionError`, :class:`DpcParseError` or
|
|
133
|
+
:class:`DpcLocationError`; ``ValueError`` for invalid arguments.
|
|
134
|
+
"""
|
|
135
|
+
_check_location(latitude, longitude)
|
|
136
|
+
if bulletin_id is None:
|
|
137
|
+
bulletin_id = await self.latest_bulletin_id(BulletinKind.CRITICALITY)
|
|
138
|
+
published = parse_criticality_id(bulletin_id)
|
|
139
|
+
offsets = _offsets(
|
|
140
|
+
published.date(),
|
|
141
|
+
len(CRITICALITY_DAYS),
|
|
142
|
+
skip_expired=skip_expired,
|
|
143
|
+
now=now,
|
|
144
|
+
)
|
|
145
|
+
days = await _gather(
|
|
146
|
+
self._criticality_day(
|
|
147
|
+
bulletin_id, offset, latitude, longitude, municipality
|
|
148
|
+
)
|
|
149
|
+
for offset in offsets
|
|
150
|
+
)
|
|
151
|
+
return CriticalityBulletin(
|
|
152
|
+
id=bulletin_id, published=published, days=tuple(days)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def get_vigilance(
|
|
156
|
+
self,
|
|
157
|
+
latitude: float,
|
|
158
|
+
longitude: float,
|
|
159
|
+
*,
|
|
160
|
+
municipality: str | None = None,
|
|
161
|
+
phenomena_radius_km: float = DEFAULT_PHENOMENA_RADIUS_KM,
|
|
162
|
+
bulletin_id: str | None = None,
|
|
163
|
+
skip_expired: bool = True,
|
|
164
|
+
now: dt.datetime | None = None,
|
|
165
|
+
) -> VigilanceBulletin:
|
|
166
|
+
"""Return the weather vigilance bulletin for a location.
|
|
167
|
+
|
|
168
|
+
Each day includes the phenomena within ``phenomena_radius_km`` of the
|
|
169
|
+
coordinates. The other arguments are as in :meth:`get_criticality`.
|
|
170
|
+
|
|
171
|
+
Raises :class:`DpcConnectionError`, :class:`DpcParseError` or
|
|
172
|
+
:class:`DpcLocationError`; ``ValueError`` for invalid arguments.
|
|
173
|
+
"""
|
|
174
|
+
_check_location(latitude, longitude)
|
|
175
|
+
if not (math.isfinite(phenomena_radius_km) and phenomena_radius_km >= 0):
|
|
176
|
+
msg = f"Invalid phenomena radius: {phenomena_radius_km}"
|
|
177
|
+
raise ValueError(msg)
|
|
178
|
+
if bulletin_id is None:
|
|
179
|
+
bulletin_id = await self.latest_bulletin_id(BulletinKind.VIGILANCE)
|
|
180
|
+
published = parse_vigilance_id(bulletin_id)
|
|
181
|
+
offsets = _offsets(
|
|
182
|
+
published, len(VIGILANCE_DAYS), skip_expired=skip_expired, now=now
|
|
183
|
+
)
|
|
184
|
+
days = await _gather(
|
|
185
|
+
self._vigilance_day(
|
|
186
|
+
bulletin_id,
|
|
187
|
+
offset,
|
|
188
|
+
latitude,
|
|
189
|
+
longitude,
|
|
190
|
+
municipality,
|
|
191
|
+
phenomena_radius_km,
|
|
192
|
+
)
|
|
193
|
+
for offset in offsets
|
|
194
|
+
)
|
|
195
|
+
return VigilanceBulletin(id=bulletin_id, date=published, days=tuple(days))
|
|
196
|
+
|
|
197
|
+
async def is_location_covered(self, latitude: float, longitude: float) -> bool:
|
|
198
|
+
"""Return whether a location is inside a DPC alert zone.
|
|
199
|
+
|
|
200
|
+
The zones of the latest criticality bulletin are used, or those of the
|
|
201
|
+
vigilance bulletin when the former cannot be fetched.
|
|
202
|
+
|
|
203
|
+
Raises the first :class:`DpcError` when no bulletin could be used.
|
|
204
|
+
"""
|
|
205
|
+
_check_location(latitude, longitude)
|
|
206
|
+
sources = (
|
|
207
|
+
(BulletinKind.CRITICALITY, criticality_geojson_url),
|
|
208
|
+
(BulletinKind.VIGILANCE, vigilance_geojson_url),
|
|
209
|
+
)
|
|
210
|
+
errors: list[DpcError] = []
|
|
211
|
+
for kind, geojson_url in sources:
|
|
212
|
+
try:
|
|
213
|
+
bulletin_id = await self.latest_bulletin_id(kind)
|
|
214
|
+
except DpcError as err:
|
|
215
|
+
errors.append(err)
|
|
216
|
+
continue
|
|
217
|
+
for offset in (0, 1):
|
|
218
|
+
try:
|
|
219
|
+
geojson = await self._fetch_json(geojson_url(bulletin_id, offset))
|
|
220
|
+
return location_in_zones(geojson, latitude, longitude)
|
|
221
|
+
except DpcError as err:
|
|
222
|
+
errors.append(err)
|
|
223
|
+
raise errors[0]
|
|
224
|
+
|
|
225
|
+
async def _criticality_day(
|
|
226
|
+
self,
|
|
227
|
+
bulletin_id: str,
|
|
228
|
+
offset: int,
|
|
229
|
+
latitude: float,
|
|
230
|
+
longitude: float,
|
|
231
|
+
municipality: str | None,
|
|
232
|
+
) -> CriticalityDay:
|
|
233
|
+
geojson = await self._fetch_json(criticality_geojson_url(bulletin_id, offset))
|
|
234
|
+
return parse_criticality_day(
|
|
235
|
+
geojson,
|
|
236
|
+
bulletin_id=bulletin_id,
|
|
237
|
+
offset=offset,
|
|
238
|
+
latitude=latitude,
|
|
239
|
+
longitude=longitude,
|
|
240
|
+
municipality=municipality,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
async def _vigilance_day(
|
|
244
|
+
self,
|
|
245
|
+
bulletin_id: str,
|
|
246
|
+
offset: int,
|
|
247
|
+
latitude: float,
|
|
248
|
+
longitude: float,
|
|
249
|
+
municipality: str | None,
|
|
250
|
+
phenomena_radius_km: float,
|
|
251
|
+
) -> VigilanceDay:
|
|
252
|
+
zones, phenomena = await _gather(
|
|
253
|
+
self._fetch_json(url(bulletin_id, offset))
|
|
254
|
+
for url in (vigilance_geojson_url, phenomena_geojson_url)
|
|
255
|
+
)
|
|
256
|
+
return parse_vigilance_day(
|
|
257
|
+
zones,
|
|
258
|
+
bulletin_id=bulletin_id,
|
|
259
|
+
offset=offset,
|
|
260
|
+
latitude=latitude,
|
|
261
|
+
longitude=longitude,
|
|
262
|
+
municipality=municipality,
|
|
263
|
+
phenomena=parse_phenomena(
|
|
264
|
+
phenomena,
|
|
265
|
+
latitude=latitude,
|
|
266
|
+
longitude=longitude,
|
|
267
|
+
radius_km=phenomena_radius_km,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
272
|
+
if self._session is None:
|
|
273
|
+
self._session = aiohttp.ClientSession()
|
|
274
|
+
self._close_session = True
|
|
275
|
+
return self._session
|
|
276
|
+
|
|
277
|
+
async def _request(self, url: str) -> bytes:
|
|
278
|
+
"""Return the body at a URL, mapping every failure to DpcConnectionError."""
|
|
279
|
+
session = self._get_session()
|
|
280
|
+
_LOGGER.debug("Fetching %s", url)
|
|
281
|
+
try:
|
|
282
|
+
async with (
|
|
283
|
+
asyncio.timeout(self._request_timeout),
|
|
284
|
+
session.get(url) as response,
|
|
285
|
+
):
|
|
286
|
+
response.raise_for_status()
|
|
287
|
+
return await response.read()
|
|
288
|
+
except TimeoutError as err:
|
|
289
|
+
msg = f"Timeout fetching {url}"
|
|
290
|
+
raise DpcConnectionError(msg) from err
|
|
291
|
+
except aiohttp.ClientResponseError as err:
|
|
292
|
+
msg = f"HTTP status {err.status} fetching {url}"
|
|
293
|
+
raise DpcConnectionError(msg) from err
|
|
294
|
+
except (aiohttp.ClientError, OSError) as err:
|
|
295
|
+
msg = f"Error fetching {url}: {err!r}"
|
|
296
|
+
raise DpcConnectionError(msg) from err
|
|
297
|
+
|
|
298
|
+
async def _fetch_text(self, url: str) -> str:
|
|
299
|
+
return (await self._request(url)).decode("utf-8", errors="replace")
|
|
300
|
+
|
|
301
|
+
async def _fetch_json(self, url: str) -> object:
|
|
302
|
+
body = await self._request(url)
|
|
303
|
+
try:
|
|
304
|
+
data: object = json.loads(body)
|
|
305
|
+
except ValueError as err:
|
|
306
|
+
msg = f"Invalid JSON from {url}"
|
|
307
|
+
raise DpcParseError(msg) from err
|
|
308
|
+
return data
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _check_location(latitude: float, longitude: float) -> None:
|
|
312
|
+
if not (math.isfinite(latitude) and -_MAX_LATITUDE <= latitude <= _MAX_LATITUDE):
|
|
313
|
+
msg = f"Invalid latitude: {latitude}"
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
if not (
|
|
316
|
+
math.isfinite(longitude) and -_MAX_LONGITUDE <= longitude <= _MAX_LONGITUDE
|
|
317
|
+
):
|
|
318
|
+
msg = f"Invalid longitude: {longitude}"
|
|
319
|
+
raise ValueError(msg)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _offsets(
|
|
323
|
+
first_day: dt.date, count: int, *, skip_expired: bool, now: dt.datetime | None
|
|
324
|
+
) -> list[int]:
|
|
325
|
+
"""Return the day offsets to fetch, without the expired days if asked."""
|
|
326
|
+
if not skip_expired:
|
|
327
|
+
return list(range(count))
|
|
328
|
+
if now is None:
|
|
329
|
+
now = dt.datetime.now(ROME_TZ)
|
|
330
|
+
elif now.tzinfo is None:
|
|
331
|
+
now = now.replace(tzinfo=ROME_TZ)
|
|
332
|
+
return [
|
|
333
|
+
offset
|
|
334
|
+
for offset in range(count)
|
|
335
|
+
if day_expires(first_day + dt.timedelta(days=offset)) > now
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _gather[T](coros: Iterable[Coroutine[Any, Any, T]]) -> list[T]:
|
|
340
|
+
"""Run coroutines concurrently and return their results in order.
|
|
341
|
+
|
|
342
|
+
On the first failure the other coroutines are cancelled and that exception
|
|
343
|
+
is raised as is (not wrapped in an ExceptionGroup). Cancellation propagates.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
async with asyncio.TaskGroup() as group:
|
|
347
|
+
tasks = [group.create_task(coro) for coro in coros]
|
|
348
|
+
except ExceptionGroup as err:
|
|
349
|
+
error = err.exceptions[0]
|
|
350
|
+
raise error from error.__cause__
|
|
351
|
+
return [task.result() for task in tasks]
|
aiodpc/const.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Constants of the DPC open data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
ROME_TZ: Final = ZoneInfo("Europe/Rome")
|
|
9
|
+
"""Time zone of the bulletins: their days are Italian calendar days."""
|
|
10
|
+
|
|
11
|
+
ATTRIBUTION: Final = (
|
|
12
|
+
"Dipartimento della Protezione Civile – Presidenza del Consiglio dei Ministri" # noqa: RUF001
|
|
13
|
+
)
|
|
14
|
+
"""Attribution required by the CC BY 4.0 license of the DPC data."""
|
|
15
|
+
|
|
16
|
+
CRITICALITY_PAGE_URL: Final = (
|
|
17
|
+
"https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
|
|
18
|
+
)
|
|
19
|
+
"""Map page of the criticality bulletin ("Bollettino di criticità")."""
|
|
20
|
+
|
|
21
|
+
VIGILANCE_PAGE_URL: Final = (
|
|
22
|
+
"https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-vigilanza/"
|
|
23
|
+
)
|
|
24
|
+
"""Map page of the weather vigilance bulletin ("Bollettino di vigilanza")."""
|
|
25
|
+
|
|
26
|
+
CRITICALITY_FILES_URL: Final = (
|
|
27
|
+
"https://raw.githubusercontent.com/pcm-dpc/"
|
|
28
|
+
"DPC-Bollettini-Criticita-Idrogeologica-Idraulica/master/files"
|
|
29
|
+
)
|
|
30
|
+
"""Base URL of the criticality bulletin files published by DPC on GitHub."""
|
|
31
|
+
|
|
32
|
+
VIGILANCE_FILES_URL: Final = (
|
|
33
|
+
"https://raw.githubusercontent.com/pcm-dpc/"
|
|
34
|
+
"DPC-Bollettini-Vigilanza-Meteorologica/master/files"
|
|
35
|
+
)
|
|
36
|
+
"""Base URL of the vigilance bulletin files published by DPC on GitHub."""
|
|
37
|
+
|
|
38
|
+
DEFAULT_REQUEST_TIMEOUT: Final = 30.0
|
|
39
|
+
"""Default timeout of each HTTP request, in seconds."""
|
|
40
|
+
|
|
41
|
+
DEFAULT_PHENOMENA_RADIUS_KM: Final = 50.0
|
|
42
|
+
"""Default radius around the location to report weather phenomena, in km."""
|
aiodpc/discovery.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Discovery of the ID of the latest bulletins.
|
|
2
|
+
|
|
3
|
+
The ID is what names the bulletin files, so it must be found before anything
|
|
4
|
+
else. The default :class:`MapPageIdSource` reads it from the DPC map pages
|
|
5
|
+
with a regular expression. Another source (for example the GitHub contents
|
|
6
|
+
API, which is rate-limited to 60 requests per hour without a token) can be
|
|
7
|
+
plugged into :class:`aiodpc.DpcClient` by implementing
|
|
8
|
+
:class:`BulletinIdSource`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
15
|
+
from typing import Final, Protocol
|
|
16
|
+
|
|
17
|
+
from .const import CRITICALITY_PAGE_URL, VIGILANCE_PAGE_URL
|
|
18
|
+
from .exceptions import DpcParseError
|
|
19
|
+
from .models import BulletinKind
|
|
20
|
+
from .parsers import parse_criticality_id, parse_vigilance_id
|
|
21
|
+
|
|
22
|
+
type TextFetcher = Callable[[str], Awaitable[str]]
|
|
23
|
+
"""Coroutine function returning the text at a URL.
|
|
24
|
+
|
|
25
|
+
It raises :class:`aiodpc.DpcConnectionError` when the URL cannot be fetched.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# 20261114_1430 (criticality) and 20261114.json (vigilance), as linked by the
|
|
29
|
+
# map pages; the lookarounds keep them from matching inside longer numbers.
|
|
30
|
+
_ID_PATTERNS: Final = {
|
|
31
|
+
BulletinKind.CRITICALITY: re.compile(r"(?<!\d)(\d{8}_\d{4})(?!\d)"),
|
|
32
|
+
BulletinKind.VIGILANCE: re.compile(r"(?<!\d)(\d{8})\.json", re.IGNORECASE),
|
|
33
|
+
}
|
|
34
|
+
_ID_PARSERS: Final[Mapping[BulletinKind, Callable[[str], object]]] = {
|
|
35
|
+
BulletinKind.CRITICALITY: parse_criticality_id,
|
|
36
|
+
BulletinKind.VIGILANCE: parse_vigilance_id,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_bulletin_id(kind: BulletinKind, text: str) -> str:
|
|
41
|
+
"""Return the latest bulletin ID of a kind found in a text (a DPC map page).
|
|
42
|
+
|
|
43
|
+
Matches that are not valid dates are ignored. When the text links more than
|
|
44
|
+
one bulletin (e.g. an "Aggiornamento" re-issue), the latest one is returned.
|
|
45
|
+
|
|
46
|
+
Raises :class:`aiodpc.DpcParseError` when the text contains no valid ID.
|
|
47
|
+
"""
|
|
48
|
+
ids: set[str] = set()
|
|
49
|
+
for bulletin_id in _ID_PATTERNS[kind].findall(text):
|
|
50
|
+
try:
|
|
51
|
+
_ID_PARSERS[kind](bulletin_id)
|
|
52
|
+
except ValueError:
|
|
53
|
+
continue
|
|
54
|
+
ids.add(bulletin_id)
|
|
55
|
+
if not ids:
|
|
56
|
+
msg = f"No {kind} bulletin ID found in the DPC page"
|
|
57
|
+
raise DpcParseError(msg)
|
|
58
|
+
# The fixed-width IDs sort chronologically.
|
|
59
|
+
return max(ids)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BulletinIdSource(Protocol):
|
|
63
|
+
"""Strategy that finds the ID of the latest bulletin of a kind."""
|
|
64
|
+
|
|
65
|
+
async def latest_id(self, kind: BulletinKind, fetch_text: TextFetcher) -> str:
|
|
66
|
+
"""Return the ID of the latest bulletin of ``kind``.
|
|
67
|
+
|
|
68
|
+
``fetch_text`` performs the HTTP requests with the client's session and
|
|
69
|
+
timeout. Raise :class:`aiodpc.DpcConnectionError` or
|
|
70
|
+
:class:`aiodpc.DpcParseError` when the ID cannot be found.
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MapPageIdSource:
|
|
76
|
+
"""Find the latest bulletin IDs in the DPC map pages (the default source)."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, page_urls: Mapping[BulletinKind, str] | None = None) -> None:
|
|
79
|
+
"""Initialize the source, optionally with other page URLs."""
|
|
80
|
+
self._page_urls: Mapping[BulletinKind, str] = page_urls or {
|
|
81
|
+
BulletinKind.CRITICALITY: CRITICALITY_PAGE_URL,
|
|
82
|
+
BulletinKind.VIGILANCE: VIGILANCE_PAGE_URL,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async def latest_id(self, kind: BulletinKind, fetch_text: TextFetcher) -> str:
|
|
86
|
+
"""Return the ID of the latest bulletin of ``kind`` linked by its page."""
|
|
87
|
+
return extract_bulletin_id(kind, await fetch_text(self._page_urls[kind]))
|
aiodpc/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Exceptions raised by aiodpc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DpcError(Exception):
|
|
7
|
+
"""Base class of the aiodpc errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DpcConnectionError(DpcError):
|
|
11
|
+
"""DPC could not be reached: network error, HTTP error status or timeout."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DpcParseError(DpcError):
|
|
15
|
+
"""DPC returned data that is not valid JSON or has an unexpected format."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DpcLocationError(DpcError):
|
|
19
|
+
"""The location is not inside any alert zone of the bulletin.
|
|
20
|
+
|
|
21
|
+
This is permanent for the given coordinates (for example a location outside
|
|
22
|
+
Italy or at sea), so retrying does not help.
|
|
23
|
+
"""
|
aiodpc/geo.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Geometry helpers on (latitude, longitude) points and GeoJSON geometries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
7
|
+
|
|
8
|
+
from geojson_utils import point_distance, point_in_polygon
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
COMPASS_POINTS: Final = (
|
|
14
|
+
"N",
|
|
15
|
+
"NNE",
|
|
16
|
+
"NE",
|
|
17
|
+
"ENE",
|
|
18
|
+
"E",
|
|
19
|
+
"ESE",
|
|
20
|
+
"SE",
|
|
21
|
+
"SSE",
|
|
22
|
+
"S",
|
|
23
|
+
"SSW",
|
|
24
|
+
"SW",
|
|
25
|
+
"WSW",
|
|
26
|
+
"W",
|
|
27
|
+
"WNW",
|
|
28
|
+
"NW",
|
|
29
|
+
"NNW",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_AREA_TYPES: Final = frozenset({"Polygon", "MultiPolygon"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def geojson_point(latitude: float, longitude: float) -> dict[str, Any]:
|
|
36
|
+
"""Return a GeoJSON Point (GeoJSON orders coordinates as longitude, latitude)."""
|
|
37
|
+
return {"type": "Point", "coordinates": [longitude, latitude]}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def point_in_area(
|
|
41
|
+
latitude: float, longitude: float, geometry: Mapping[str, Any]
|
|
42
|
+
) -> bool:
|
|
43
|
+
"""Return whether a point is inside a Polygon or MultiPolygon geometry.
|
|
44
|
+
|
|
45
|
+
Points on the boundary count as inside; other geometry types never
|
|
46
|
+
contain a point.
|
|
47
|
+
"""
|
|
48
|
+
if geometry.get("type") not in _AREA_TYPES:
|
|
49
|
+
return False
|
|
50
|
+
return bool(point_in_polygon(geojson_point(latitude, longitude), dict(geometry)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def distance_km(point_a: tuple[float, float], point_b: tuple[float, float]) -> float:
|
|
54
|
+
"""Return the great-circle distance in km between two (lat, lon) points."""
|
|
55
|
+
meters = point_distance(geojson_point(*point_a), geojson_point(*point_b))
|
|
56
|
+
return float(meters) / 1000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def calculate_initial_compass_bearing(
|
|
60
|
+
point_a: tuple[float, float], point_b: tuple[float, float]
|
|
61
|
+
) -> tuple[str, int]:
|
|
62
|
+
"""Return the compass point and degrees from point_a to point_b (lat, lon).
|
|
63
|
+
|
|
64
|
+
Credits: https://gist.github.com/jeromer/2005586
|
|
65
|
+
and https://gist.github.com/RobertSudwarts/acf8df23a16afdb5837f
|
|
66
|
+
"""
|
|
67
|
+
lat1 = math.radians(point_a[0])
|
|
68
|
+
lat2 = math.radians(point_b[0])
|
|
69
|
+
diff_long = math.radians(point_b[1] - point_a[1])
|
|
70
|
+
x = math.sin(diff_long) * math.cos(lat2)
|
|
71
|
+
y = math.cos(lat1) * math.sin(lat2) - (
|
|
72
|
+
math.sin(lat1) * math.cos(lat2) * math.cos(diff_long)
|
|
73
|
+
)
|
|
74
|
+
compass_bearing = (math.degrees(math.atan2(x, y)) + 360) % 360
|
|
75
|
+
index = round(compass_bearing / (360.0 / len(COMPASS_POINTS)))
|
|
76
|
+
return COMPASS_POINTS[index % len(COMPASS_POINTS)], math.floor(compass_bearing)
|