PyMetno 0.12.0__tar.gz → 0.14.0__tar.gz
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.
- pymetno-0.14.0/MANIFEST.in +3 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/PKG-INFO +9 -2
- {PyMetno-0.12.0 → pymetno-0.14.0}/PyMetno.egg-info/PKG-INFO +9 -2
- {PyMetno-0.12.0 → pymetno-0.14.0}/PyMetno.egg-info/SOURCES.txt +6 -1
- pymetno-0.14.0/README.md +71 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/metno/__init__.py +222 -23
- pymetno-0.14.0/metno/cache.py +71 -0
- pymetno-0.14.0/metno/exceptions.py +25 -0
- pymetno-0.14.0/pytest.ini +3 -0
- pymetno-0.14.0/requirements-test.txt +2 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/setup.py +1 -1
- pymetno-0.14.0/tests/test_fetch.py +850 -0
- PyMetno-0.12.0/MANIFEST.in +0 -1
- PyMetno-0.12.0/README.md +0 -4
- {PyMetno-0.12.0 → pymetno-0.14.0}/LICENSE +0 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/PyMetno.egg-info/dependency_links.txt +0 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/PyMetno.egg-info/requires.txt +0 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/PyMetno.egg-info/top_level.txt +0 -0
- {PyMetno-0.12.0 → pymetno-0.14.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: PyMetno
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: A library to communicate with the met.no api
|
|
5
5
|
Home-page: https://github.com/Danielhiversen/pyMetno/
|
|
6
6
|
Author: Daniel Hjelseth Høyer
|
|
@@ -16,3 +16,10 @@ License-File: LICENSE
|
|
|
16
16
|
Requires-Dist: xmltodict
|
|
17
17
|
Requires-Dist: aiohttp>=3.0.6
|
|
18
18
|
Requires-Dist: async_timeout>=3.0.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
Dynamic: requires-dist
|
|
25
|
+
Dynamic: summary
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: PyMetno
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: A library to communicate with the met.no api
|
|
5
5
|
Home-page: https://github.com/Danielhiversen/pyMetno/
|
|
6
6
|
Author: Daniel Hjelseth Høyer
|
|
@@ -16,3 +16,10 @@ License-File: LICENSE
|
|
|
16
16
|
Requires-Dist: xmltodict
|
|
17
17
|
Requires-Dist: aiohttp>=3.0.6
|
|
18
18
|
Requires-Dist: async_timeout>=3.0.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: license
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
Dynamic: requires-dist
|
|
25
|
+
Dynamic: summary
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
MANIFEST.in
|
|
3
3
|
README.md
|
|
4
|
+
pytest.ini
|
|
5
|
+
requirements-test.txt
|
|
4
6
|
setup.py
|
|
5
7
|
PyMetno.egg-info/PKG-INFO
|
|
6
8
|
PyMetno.egg-info/SOURCES.txt
|
|
7
9
|
PyMetno.egg-info/dependency_links.txt
|
|
8
10
|
PyMetno.egg-info/requires.txt
|
|
9
11
|
PyMetno.egg-info/top_level.txt
|
|
10
|
-
metno/__init__.py
|
|
12
|
+
metno/__init__.py
|
|
13
|
+
metno/cache.py
|
|
14
|
+
metno/exceptions.py
|
|
15
|
+
tests/test_fetch.py
|
pymetno-0.14.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# pyMetno [](https://travis-ci.org/Danielhiversen/pyMetno)
|
|
2
|
+
Python library to talk to the met.no api
|
|
3
|
+
|
|
4
|
+
[Buy me a coffee :)](http://paypal.me/dahoiv)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## Fetching Locationforecast data
|
|
8
|
+
|
|
9
|
+
Pass an `aiohttp.ClientSession` to `MetWeatherData` and keep the session open
|
|
10
|
+
for the lifetime of the client:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import aiohttp
|
|
14
|
+
from metno import MetNoError, MetWeatherData
|
|
15
|
+
|
|
16
|
+
async def forecast():
|
|
17
|
+
async with aiohttp.ClientSession(headers={"User-Agent": "my-app/1.0 contact@example.com"}) as session:
|
|
18
|
+
weather = MetWeatherData({"lat": 60, "lon": 10, "altitude": 0}, session)
|
|
19
|
+
try:
|
|
20
|
+
await weather.fetch_data()
|
|
21
|
+
except MetNoError as error:
|
|
22
|
+
print(error, weather.next_fetch_at)
|
|
23
|
+
return None
|
|
24
|
+
return weather.data
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`await fetch_data()` returns `True` on success and raises `MetNoError` on
|
|
28
|
+
failure. Subclasses distinguish `MetNoHttpError` (with a `status` attribute),
|
|
29
|
+
`MetNoPayloadError` (JSON, schema or an unexpected 304), `MetNoTimeoutError`,
|
|
30
|
+
and `MetNoTransportError`. The legacy `await fetching_data(*args)` continues
|
|
31
|
+
to return `True` or `False`. Both APIs expose the latest typed failure through
|
|
32
|
+
`last_error`, clear it on success, and propagate task cancellation. The client
|
|
33
|
+
does not log fetch failures; callers can choose their own reporting policy.
|
|
34
|
+
A valid HTTP 203 response succeeds and follows the normal cache policy. Each
|
|
35
|
+
received 203 logs a deprecation warning as required by the
|
|
36
|
+
[MET terms of service](https://api.met.no/doc/TermsOfService); cache hits do not
|
|
37
|
+
repeat the warning.
|
|
38
|
+
|
|
39
|
+
A successful download or 304 revalidation is cached for **at least three
|
|
40
|
+
hours**, for all geographic regions. A later valid HTTP `Expires` extends that
|
|
41
|
+
deadline. Explicit/manual calls obey the same cache policy. `next_fetch_at`
|
|
42
|
+
is an aware UTC datetime for the next permitted attempt, or `None` before
|
|
43
|
+
fetching. Revalidation sends the exact `Last-Modified` value as
|
|
44
|
+
`If-Modified-Since`; a 304 reuses the validated body without decoding a body.
|
|
45
|
+
The timeout covers response acquisition, body reading and JSON decoding.
|
|
46
|
+
Invalid responses leave the last known good representation and its validator
|
|
47
|
+
intact; invalid new bodies never supply validators.
|
|
48
|
+
|
|
49
|
+
Each client keeps an in-memory cache keyed by the endpoint and normalized
|
|
50
|
+
request: latitude/longitude rounded to four decimal places and altitude
|
|
51
|
+
truncated to an integer (`msl` remains a constructor alias for altitude).
|
|
52
|
+
Different keys have independent bodies, validators, errors and deadlines;
|
|
53
|
+
returning to a key reuses its cache. Requests on a client are serialized to
|
|
54
|
+
avoid concurrent duplicate downloads. Nothing is shared across clients or
|
|
55
|
+
persisted, and the client starts no background work.
|
|
56
|
+
|
|
57
|
+
Failures schedule a later attempt instead of retrying inside the call.
|
|
58
|
+
Backoff begins at **60 seconds**, doubles after consecutive failures, adds
|
|
59
|
+
positive random jitter of up to 10%, and caps the total delay at **one hour**.
|
|
60
|
+
This cooldown also applies to non-transient HTTP failures to avoid repeated
|
|
61
|
+
manual calls. Later valid `Retry-After` (seconds or HTTP-date), `Expires`, and
|
|
62
|
+
an existing cache deadline take precedence. Successful downloading or
|
|
63
|
+
revalidation resets backoff. Calls during a failure cooldown return `False`
|
|
64
|
+
or raise the same typed error, even when older usable data is still present.
|
|
65
|
+
The caller decides when to call again; no sleeps or retry loops are started.
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
uv run --no-project --with-requirements requirements-test.txt --with aiohttp --with async-timeout python -m pytest
|
|
71
|
+
```
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import datetime
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
|
|
7
|
+
from collections.abc import Mapping
|
|
6
8
|
from typing import Any, List
|
|
7
9
|
|
|
8
10
|
import aiohttp
|
|
9
11
|
import async_timeout
|
|
10
12
|
|
|
13
|
+
from .cache import CacheEntry
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
MetNoError,
|
|
16
|
+
MetNoHttpError,
|
|
17
|
+
MetNoPayloadError,
|
|
18
|
+
MetNoTimeoutError,
|
|
19
|
+
MetNoTransportError,
|
|
20
|
+
)
|
|
21
|
+
|
|
11
22
|
try:
|
|
12
23
|
import ciso8601
|
|
13
24
|
except ImportError:
|
|
@@ -83,16 +94,115 @@ TIMEOUT = 30
|
|
|
83
94
|
_LOGGER = logging.getLogger(__name__)
|
|
84
95
|
|
|
85
96
|
|
|
97
|
+
def _utcnow():
|
|
98
|
+
"""Return an aware UTC clock value."""
|
|
99
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _normalize_params(urlparams):
|
|
103
|
+
"""Use the same precision for request identity and wire parameters."""
|
|
104
|
+
return {
|
|
105
|
+
"lat": str(round(float(urlparams["lat"]), 4) or 0.0),
|
|
106
|
+
"lon": str(round(float(urlparams["lon"]), 4) or 0.0),
|
|
107
|
+
"altitude": str(int(float(urlparams.get("altitude", urlparams.get("msl", 0))))),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_INSTANT_NUMERIC_FIELDS = (
|
|
112
|
+
"air_temperature",
|
|
113
|
+
"air_pressure_at_sea_level",
|
|
114
|
+
"relative_humidity",
|
|
115
|
+
"dew_point_temperature",
|
|
116
|
+
"ultraviolet_index_clear_sky",
|
|
117
|
+
"wind_speed",
|
|
118
|
+
"wind_speed_of_gust",
|
|
119
|
+
"wind_from_direction",
|
|
120
|
+
"fog_area_fraction",
|
|
121
|
+
"cloud_area_fraction",
|
|
122
|
+
"cloud_area_fraction_low",
|
|
123
|
+
"cloud_area_fraction_medium",
|
|
124
|
+
"cloud_area_fraction_high",
|
|
125
|
+
)
|
|
126
|
+
_PERIOD_NUMERIC_FIELDS = (
|
|
127
|
+
"precipitation_amount",
|
|
128
|
+
"precipitation_amount_max",
|
|
129
|
+
"precipitation_amount_min",
|
|
130
|
+
"probability_of_precipitation",
|
|
131
|
+
"probability_of_thunder",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_numeric_details(details, fields):
|
|
136
|
+
"""Require present weather measurements to be finite numbers."""
|
|
137
|
+
for field in fields:
|
|
138
|
+
if field in details:
|
|
139
|
+
value = details[field]
|
|
140
|
+
if (
|
|
141
|
+
isinstance(value, bool)
|
|
142
|
+
or not isinstance(value, (int, float))
|
|
143
|
+
or not math.isfinite(value)
|
|
144
|
+
):
|
|
145
|
+
raise ValueError(f"Invalid numeric weather value: {field}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_payload(payload):
|
|
149
|
+
"""Require usable timestamped instant data, leaving weather fields optional."""
|
|
150
|
+
try:
|
|
151
|
+
if not isinstance(payload, Mapping) or not isinstance(payload["properties"], Mapping):
|
|
152
|
+
raise ValueError("Missing forecast properties")
|
|
153
|
+
timeseries = payload["properties"]["timeseries"]
|
|
154
|
+
if not isinstance(timeseries, list) or not timeseries:
|
|
155
|
+
raise ValueError("Missing forecast timeseries")
|
|
156
|
+
previous_timestamp = None
|
|
157
|
+
for entry in timeseries:
|
|
158
|
+
if not isinstance(entry, Mapping) or not isinstance(entry["time"], str):
|
|
159
|
+
raise ValueError("Invalid forecast entry")
|
|
160
|
+
timestamp = parse_datetime(entry["time"])
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(timestamp, datetime.datetime)
|
|
163
|
+
or timestamp.tzinfo is None
|
|
164
|
+
or timestamp.utcoffset() is None
|
|
165
|
+
):
|
|
166
|
+
raise ValueError("Forecast timestamp must include a timezone")
|
|
167
|
+
if previous_timestamp is not None and timestamp < previous_timestamp:
|
|
168
|
+
raise ValueError("Forecast timestamps are out of order")
|
|
169
|
+
previous_timestamp = timestamp
|
|
170
|
+
data = entry["data"]
|
|
171
|
+
if not isinstance(data, Mapping) or not isinstance(data["instant"], Mapping):
|
|
172
|
+
raise ValueError("Missing instant data")
|
|
173
|
+
instant_details = data["instant"]["details"]
|
|
174
|
+
if not isinstance(instant_details, Mapping):
|
|
175
|
+
raise ValueError("Missing instant details")
|
|
176
|
+
_validate_numeric_details(instant_details, _INSTANT_NUMERIC_FIELDS)
|
|
177
|
+
for period in ("next_1_hours", "next_6_hours", "next_12_hours"):
|
|
178
|
+
if period not in data:
|
|
179
|
+
continue
|
|
180
|
+
forecast = data[period]
|
|
181
|
+
if not isinstance(forecast, Mapping):
|
|
182
|
+
raise ValueError("Invalid forecast period")
|
|
183
|
+
if "details" in forecast:
|
|
184
|
+
details = forecast["details"]
|
|
185
|
+
if not isinstance(details, Mapping):
|
|
186
|
+
raise ValueError("Invalid forecast details")
|
|
187
|
+
_validate_numeric_details(details, _PERIOD_NUMERIC_FIELDS)
|
|
188
|
+
if "summary" in forecast:
|
|
189
|
+
summary = forecast["summary"]
|
|
190
|
+
if not isinstance(summary, Mapping):
|
|
191
|
+
raise ValueError("Invalid forecast summary")
|
|
192
|
+
if "symbol_code" in summary and not isinstance(
|
|
193
|
+
summary["symbol_code"], str
|
|
194
|
+
):
|
|
195
|
+
raise ValueError("Invalid weather symbol")
|
|
196
|
+
except (KeyError, TypeError, ValueError, OverflowError) as error:
|
|
197
|
+
raise MetNoPayloadError("Invalid Locationforecast representation") from error
|
|
198
|
+
|
|
199
|
+
|
|
86
200
|
class MetWeatherData:
|
|
87
201
|
"""Representation of met weather data."""
|
|
88
202
|
|
|
89
203
|
def __init__(self, urlparams, websession=None, api_url=DEFAULT_API_URL):
|
|
90
204
|
"""Initialize the Weather object."""
|
|
91
|
-
|
|
92
|
-
"lon": str(round(float(urlparams['lon']), 4)),
|
|
93
|
-
"altitude": str(int(float(urlparams.get('altitude', urlparams.get('msl', 0))))),
|
|
94
|
-
}
|
|
95
|
-
self._urlparams = urlparams
|
|
205
|
+
self._urlparams = _normalize_params(urlparams)
|
|
96
206
|
self._api_url = api_url
|
|
97
207
|
if websession is None:
|
|
98
208
|
|
|
@@ -104,32 +214,112 @@ class MetWeatherData:
|
|
|
104
214
|
else:
|
|
105
215
|
self._websession = websession
|
|
106
216
|
self.data = None
|
|
217
|
+
self._cache = {}
|
|
218
|
+
self._fetch_lock = asyncio.Lock()
|
|
219
|
+
|
|
220
|
+
def _entry(self):
|
|
221
|
+
params = _normalize_params(self._urlparams)
|
|
222
|
+
key = (self._api_url, tuple(sorted(params.items())))
|
|
223
|
+
return self._cache.setdefault(key, CacheEntry())
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def last_error(self):
|
|
227
|
+
"""Return the current request's most recent typed failure, if any."""
|
|
228
|
+
return self._entry().last_error
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def next_fetch_at(self):
|
|
232
|
+
"""Return the current request's earliest next attempt as aware UTC."""
|
|
233
|
+
return self._entry().next_fetch_at
|
|
107
234
|
|
|
108
235
|
async def fetching_data(self, *_):
|
|
109
|
-
"""
|
|
236
|
+
"""Return success for usable fresh data, retaining the legacy boolean API."""
|
|
110
237
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if resp.status >= 400:
|
|
114
|
-
_LOGGER.error("%s returned %s", self._api_url, resp.status)
|
|
115
|
-
return False
|
|
116
|
-
self.data = await resp.json()
|
|
117
|
-
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
|
118
|
-
_LOGGER.error("Access to %s returned error '%s'", self._api_url, type(err).__name__)
|
|
119
|
-
return False
|
|
120
|
-
except ValueError:
|
|
121
|
-
_LOGGER.exception("Unable to parse json response from %s", self._api_url)
|
|
238
|
+
await self.fetch_data()
|
|
239
|
+
except MetNoError:
|
|
122
240
|
return False
|
|
123
241
|
return True
|
|
124
242
|
|
|
243
|
+
async def fetch_data(self) -> bool:
|
|
244
|
+
"""Return True for a fetched/cached forecast, raising typed failures."""
|
|
245
|
+
async with self._fetch_lock:
|
|
246
|
+
params = _normalize_params(self._urlparams)
|
|
247
|
+
api_url = self._api_url
|
|
248
|
+
key = (api_url, tuple(sorted(params.items())))
|
|
249
|
+
entry = self._cache.setdefault(key, CacheEntry())
|
|
250
|
+
self.data = entry.data
|
|
251
|
+
now = _utcnow()
|
|
252
|
+
if entry.next_fetch_at is not None and now < entry.next_fetch_at:
|
|
253
|
+
if entry.last_error is not None:
|
|
254
|
+
raise entry.last_error.with_traceback(None)
|
|
255
|
+
return True
|
|
256
|
+
request_headers = {}
|
|
257
|
+
if entry.data is not None and entry.last_modified is not None:
|
|
258
|
+
request_headers["If-Modified-Since"] = entry.last_modified
|
|
259
|
+
response_headers = {}
|
|
260
|
+
try:
|
|
261
|
+
async with async_timeout.timeout(TIMEOUT):
|
|
262
|
+
async with self._websession.get(
|
|
263
|
+
api_url, params=params, headers=request_headers
|
|
264
|
+
) as response:
|
|
265
|
+
response_headers = response.headers
|
|
266
|
+
if response.status == 203:
|
|
267
|
+
_LOGGER.warning("%s is deprecated (HTTP 203)", api_url)
|
|
268
|
+
if response.status == 304:
|
|
269
|
+
if entry.data is None:
|
|
270
|
+
raise MetNoPayloadError("HTTP 304 without a cached representation")
|
|
271
|
+
payload = entry.data
|
|
272
|
+
last_modified = response.headers.get("Last-Modified", entry.last_modified)
|
|
273
|
+
elif response.status not in (200, 203):
|
|
274
|
+
raise MetNoHttpError(response.status)
|
|
275
|
+
else:
|
|
276
|
+
try:
|
|
277
|
+
payload = await response.json()
|
|
278
|
+
except (ValueError, UnicodeError, aiohttp.ContentTypeError) as error:
|
|
279
|
+
raise MetNoPayloadError("Invalid Locationforecast JSON") from error
|
|
280
|
+
_validate_payload(payload)
|
|
281
|
+
last_modified = response.headers.get("Last-Modified")
|
|
282
|
+
except MetNoError as error:
|
|
283
|
+
entry.failed(_utcnow(), response_headers, error)
|
|
284
|
+
raise
|
|
285
|
+
except asyncio.TimeoutError as error:
|
|
286
|
+
failure = MetNoTimeoutError("Locationforecast request timed out")
|
|
287
|
+
entry.failed(_utcnow(), response_headers, failure)
|
|
288
|
+
raise failure from error
|
|
289
|
+
except aiohttp.ClientError as error:
|
|
290
|
+
if isinstance(error, aiohttp.ClientResponseError):
|
|
291
|
+
status = getattr(error, "status", None)
|
|
292
|
+
if status is None:
|
|
293
|
+
status = error.code
|
|
294
|
+
if status >= 400:
|
|
295
|
+
failure = MetNoHttpError(status)
|
|
296
|
+
entry.failed(_utcnow(), error.headers or response_headers, failure)
|
|
297
|
+
raise failure from error
|
|
298
|
+
failure = MetNoTransportError("Locationforecast transport failed")
|
|
299
|
+
entry.failed(_utcnow(), response_headers, failure)
|
|
300
|
+
raise failure from error
|
|
301
|
+
entry.data = payload
|
|
302
|
+
entry.last_modified = last_modified
|
|
303
|
+
entry.succeeded(_utcnow(), response_headers)
|
|
304
|
+
self.data = payload
|
|
305
|
+
return True
|
|
306
|
+
|
|
125
307
|
def get_current_weather(self):
|
|
126
308
|
"""Get the current weather data from met.no."""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
309
|
+
if self.data is None:
|
|
310
|
+
return {}
|
|
311
|
+
timestamps = [
|
|
312
|
+
parse_datetime(entry["time"]).astimezone(datetime.timezone.utc)
|
|
313
|
+
for entry in self.data["properties"]["timeseries"]
|
|
314
|
+
]
|
|
315
|
+
now = _utcnow().astimezone(datetime.timezone.utc)
|
|
316
|
+
if not timestamps or now > max(timestamps):
|
|
317
|
+
return {}
|
|
318
|
+
current = max(
|
|
319
|
+
(timestamp for timestamp in timestamps if timestamp <= now),
|
|
320
|
+
default=min(timestamps),
|
|
321
|
+
)
|
|
322
|
+
return self.get_weather(current, hourly=True)
|
|
133
323
|
|
|
134
324
|
def get_forecast(self, time_zone, hourly=False, range_start=1, range_stop=None):
|
|
135
325
|
"""Get the forecast weather data from met.no."""
|
|
@@ -167,6 +357,7 @@ class MetWeatherData:
|
|
|
167
357
|
daily_windspeed = []
|
|
168
358
|
daily_windgust = []
|
|
169
359
|
daily_dew_point = []
|
|
360
|
+
daily_uv_index = []
|
|
170
361
|
entries = []
|
|
171
362
|
|
|
172
363
|
for time_entry in self.data["properties"]["timeseries"]:
|
|
@@ -194,6 +385,9 @@ class MetWeatherData:
|
|
|
194
385
|
dew_point = get_data("dew_point_temperature", [time_entry])
|
|
195
386
|
if dew_point is not None:
|
|
196
387
|
daily_dew_point.append(dew_point)
|
|
388
|
+
uv_index = get_data("ultraviolet_index_clear_sky", [time_entry])
|
|
389
|
+
if uv_index is not None:
|
|
390
|
+
daily_uv_index.append(uv_index)
|
|
197
391
|
|
|
198
392
|
if time.astimezone() <= timestamp:
|
|
199
393
|
entries.append(time_entry)
|
|
@@ -214,6 +408,7 @@ class MetWeatherData:
|
|
|
214
408
|
res["wind_gust"] = get_data("wind_speed_of_gust", entries)
|
|
215
409
|
res["cloudiness"] = get_data("cloud_area_fraction", entries)
|
|
216
410
|
res["dew_point"] = get_data("dew_point_temperature", entries)
|
|
411
|
+
res["uv_index"] = get_data("ultraviolet_index_clear_sky", entries)
|
|
217
412
|
else:
|
|
218
413
|
res["temperature"] = (
|
|
219
414
|
None if daily_temperatures == [] else max(daily_temperatures)
|
|
@@ -236,6 +431,9 @@ class MetWeatherData:
|
|
|
236
431
|
res["dew_point"] = (
|
|
237
432
|
None if daily_dew_point == [] else max(daily_dew_point)
|
|
238
433
|
)
|
|
434
|
+
res["uv_index"] = (
|
|
435
|
+
None if daily_uv_index == [] else max(daily_uv_index)
|
|
436
|
+
)
|
|
239
437
|
|
|
240
438
|
return res
|
|
241
439
|
|
|
@@ -283,6 +481,7 @@ def get_data(param: str, data: List[dict]) -> Any:
|
|
|
283
481
|
"air_pressure_at_sea_level",
|
|
284
482
|
"relative_humidity",
|
|
285
483
|
"dew_point_temperature",
|
|
484
|
+
"ultraviolet_index_clear_sky",
|
|
286
485
|
):
|
|
287
486
|
new_state = instant_details[param]
|
|
288
487
|
elif param in ("wind_speed", "wind_speed_of_gust"):
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Instance cache records and conservative server deadline handling."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
MIN_FETCH_INTERVAL = datetime.timedelta(hours=3)
|
|
8
|
+
RETRY_BASE_SECONDS = 60
|
|
9
|
+
RETRY_MAX_SECONDS = 3600
|
|
10
|
+
UTC = datetime.timezone.utc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CacheEntry:
|
|
14
|
+
"""State belonging to one normalized endpoint and location."""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.data = None
|
|
18
|
+
self.last_modified = None
|
|
19
|
+
self.next_fetch_at = None
|
|
20
|
+
self.last_error = None
|
|
21
|
+
self.failures = 0
|
|
22
|
+
|
|
23
|
+
def succeeded(self, now, headers):
|
|
24
|
+
"""Apply freshness metadata after validation or revalidation."""
|
|
25
|
+
self.next_fetch_at = max(
|
|
26
|
+
now + MIN_FETCH_INTERVAL, http_date(headers.get("Expires")) or now
|
|
27
|
+
)
|
|
28
|
+
self.last_error = None
|
|
29
|
+
self.failures = 0
|
|
30
|
+
|
|
31
|
+
def failed(self, now, headers, error):
|
|
32
|
+
"""Schedule a later attempt without replacing a valid representation."""
|
|
33
|
+
self.failures = min(self.failures + 1, 7)
|
|
34
|
+
jitter_fraction = 0.1
|
|
35
|
+
base = min(
|
|
36
|
+
RETRY_MAX_SECONDS / (1 + jitter_fraction),
|
|
37
|
+
RETRY_BASE_SECONDS * 2 ** (self.failures - 1),
|
|
38
|
+
)
|
|
39
|
+
delay = base + random.uniform(0.001, base * jitter_fraction)
|
|
40
|
+
self.next_fetch_at = max(
|
|
41
|
+
self.next_fetch_at or now,
|
|
42
|
+
now + datetime.timedelta(seconds=delay),
|
|
43
|
+
http_date(headers.get("Expires")) or now,
|
|
44
|
+
retry_after(headers.get("Retry-After"), now) or now,
|
|
45
|
+
)
|
|
46
|
+
self.last_error = error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def http_date(value):
|
|
50
|
+
"""Parse an HTTP date, ignoring malformed or out-of-range headers."""
|
|
51
|
+
if not value:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
parsed = parsedate_to_datetime(value)
|
|
55
|
+
if parsed.tzinfo is None:
|
|
56
|
+
return None
|
|
57
|
+
return parsed.astimezone(UTC)
|
|
58
|
+
except (TypeError, ValueError, OverflowError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def retry_after(value, now):
|
|
63
|
+
"""Support both delay-seconds and HTTP-date Retry-After values."""
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
if value.isascii() and value.isdecimal():
|
|
67
|
+
try:
|
|
68
|
+
return now + datetime.timedelta(seconds=int(value))
|
|
69
|
+
except (ValueError, OverflowError):
|
|
70
|
+
return None
|
|
71
|
+
return http_date(value)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Typed failures from the Locationforecast client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MetNoError(Exception):
|
|
5
|
+
"""Base exception for a failed forecast fetch."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MetNoHttpError(MetNoError):
|
|
9
|
+
"""The server returned an unsuccessful HTTP status."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status):
|
|
12
|
+
self.status = status
|
|
13
|
+
super().__init__("Locationforecast returned HTTP {}".format(status))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MetNoPayloadError(MetNoError):
|
|
17
|
+
"""The response does not contain a usable forecast representation."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MetNoTimeoutError(MetNoError):
|
|
21
|
+
"""Response acquisition or body reading exceeded the timeout."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetNoTransportError(MetNoError):
|
|
25
|
+
"""The forecast request failed at the transport layer."""
|
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name = 'PyMetno',
|
|
5
5
|
packages = ['metno'],
|
|
6
6
|
install_requires=['xmltodict', "aiohttp>=3.0.6", "async_timeout>=3.0.0"],
|
|
7
|
-
version = '0.
|
|
7
|
+
version = '0.14.0',
|
|
8
8
|
description = 'A library to communicate with the met.no api',
|
|
9
9
|
author='Daniel Hjelseth Høyer',
|
|
10
10
|
url='https://github.com/Danielhiversen/pyMetno/',
|