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.
@@ -0,0 +1,3 @@
1
+ include LICENSE
2
+ include pytest.ini
3
+ include requirements-test.txt
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: PyMetno
3
- Version: 0.12.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
1
+ Metadata-Version: 2.4
2
2
  Name: PyMetno
3
- Version: 0.12.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
@@ -0,0 +1,71 @@
1
+ # pyMetno [![Build Status](https://travis-ci.org/Danielhiversen/pyMetno.svg?branch=master)](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
- urlparams = {"lat": str(round(float(urlparams['lat']), 4)),
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
- """Get the latest data from met.no."""
236
+ """Return success for usable fresh data, retaining the legacy boolean API."""
110
237
  try:
111
- async with async_timeout.timeout(TIMEOUT):
112
- resp = await self._websession.get(self._api_url, params=self._urlparams)
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
- try:
128
- timeseries = self.data["properties"]["timeseries"]
129
- now = parse_datetime(timeseries[0]["time"])
130
- except (TypeError, KeyError, IndexError):
131
- now = datetime.datetime.now(datetime.timezone.utc)
132
- return self.get_weather(now, hourly=True)
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."""
@@ -0,0 +1,3 @@
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ asyncio_default_fixture_loop_scope = function
@@ -0,0 +1,2 @@
1
+ pytest>=8
2
+ pytest-asyncio>=0.24
@@ -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.12.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/',