env-canada 0.8.0__py3-none-any.whl → 0.9.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.
env_canada/__init__.py CHANGED
@@ -5,10 +5,11 @@ __all__ = [
5
5
  "ECHydro",
6
6
  "ECRadar",
7
7
  "ECWeather",
8
+ "ECWeatherUpdateFailed",
8
9
  ]
9
10
 
10
11
  from .ec_aqhi import ECAirQuality
11
12
  from .ec_historical import ECHistorical, ECHistoricalRange
12
13
  from .ec_hydro import ECHydro
13
14
  from .ec_radar import ECRadar
14
- from .ec_weather import ECWeather
15
+ from .ec_weather import ECWeather, ECWeatherUpdateFailed
env_canada/ec_weather.py CHANGED
@@ -1,13 +1,19 @@
1
1
  import csv
2
- import datetime
3
2
  import logging
4
3
  import re
5
-
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
6
  import voluptuous as vol
7
- from aiohttp import ClientSession
8
- from dateutil import parser, relativedelta, tz
7
+ from aiohttp import (
8
+ ClientConnectorDNSError,
9
+ ClientResponseError,
10
+ ClientSession,
11
+ ClientTimeout,
12
+ )
13
+ from dateutil import parser, tz
9
14
  from geopy import distance
10
15
  from lxml import etree as et
16
+ from lxml.etree import _Element
11
17
 
12
18
  from . import ec_exc
13
19
  from .constants import USER_AGENT
@@ -16,6 +22,8 @@ SITE_LIST_URL = "https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv
16
22
 
17
23
  WEATHER_URL = "https://dd.weather.gc.ca/citypage_weather/xml/{}_{}.xml"
18
24
 
25
+ CLIENT_TIMEOUT = ClientTimeout(10)
26
+
19
27
  LOG = logging.getLogger(__name__)
20
28
 
21
29
  ATTRIBUTION = {
@@ -24,7 +32,17 @@ ATTRIBUTION = {
24
32
  }
25
33
 
26
34
 
27
- __all__ = ["ECWeather"]
35
+ __all__ = ["ECWeather", "ECWeatherUpdateFailed"]
36
+
37
+
38
+ @dataclass
39
+ class MetaData:
40
+ attribution: str
41
+ timestamp: datetime = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
42
+ station: str | None = None
43
+ location: str | None = None
44
+ cache_returned_on_update: int = 0 # Resets to 0 after successful update
45
+ last_update_error: str = ""
28
46
 
29
47
 
30
48
  conditions_meta = {
@@ -179,35 +197,30 @@ summary_meta = {
179
197
  "label": {"english": "Forecast", "french": "Prévision"},
180
198
  }
181
199
 
182
- alerts_meta = {
183
- "warnings": {
184
- "english": {"label": "Warnings", "pattern": ".*WARNING((?!ENDED).)*$"},
185
- "french": {
186
- "label": "Alertes",
187
- "pattern": ".*(ALERTE|AVERTISSEMENT)((?!TERMINÉ).)*$",
188
- },
189
- },
190
- "watches": {
191
- "english": {"label": "Watches", "pattern": ".*WATCH((?!ENDED).)*$"},
192
- "french": {"label": "Veilles", "pattern": ".*VEILLE((?!TERMINÉ).)*$"},
200
+ ALERTS_INIT = {
201
+ "english": {
202
+ "warnings": {"label": "Warnings", "value": []},
203
+ "watches": {"label": "Watches", "value": []},
204
+ "advisories": {"label": "Advisories", "value": []},
205
+ "statements": {"label": "Statements", "value": []},
206
+ "endings": {"label": "Endings", "value": []},
193
207
  },
194
- "advisories": {
195
- "english": {"label": "Advisories", "pattern": ".*ADVISORY((?!ENDED).)*$"},
196
- "french": {"label": "Avis", "pattern": ".*AVIS((?!TERMINÉ).)*$"},
197
- },
198
- "statements": {
199
- "english": {"label": "Statements", "pattern": ".*STATEMENT((?!ENDED).)*$"},
200
- "french": {"label": "Bulletins", "pattern": ".*BULLETIN((?!TERMINÉ).)*$"},
201
- },
202
- "endings": {
203
- "english": {"label": "Endings", "pattern": ".*ENDED"},
204
- "french": {"label": "Terminaisons", "pattern": ".*TERMINÉE?"},
208
+ "french": {
209
+ "warnings": {"label": "Alertes", "value": []},
210
+ "watches": {"label": "Veilles", "value": []},
211
+ "advisories": {"label": "Avis", "value": []},
212
+ "statements": {"label": "Bulletins", "value": []},
213
+ "endings": {"label": "Terminaisons", "value": []},
205
214
  },
206
215
  }
207
216
 
208
- metadata_meta = {
209
- "timestamp": {"xpath": "./dateTime/timeStamp"},
210
- "location": {"xpath": "./location/name"},
217
+ # Maps "type" in XML alert attribute to name used
218
+ ALERT_TYPE_TO_NAME = {
219
+ "advisory": "advisories",
220
+ "ending": "endings",
221
+ "statement": "statements",
222
+ "warning": "warnings",
223
+ "watch": "watches",
211
224
  }
212
225
 
213
226
 
@@ -220,8 +233,15 @@ def validate_station(station):
220
233
  return station
221
234
 
222
235
 
223
- def parse_timestamp(t):
224
- return parser.parse(t).replace(tzinfo=tz.UTC)
236
+ def _parse_timestamp(time_str: str | None) -> datetime | None:
237
+ if time_str is not None:
238
+ return parser.parse(time_str).replace(tzinfo=tz.UTC)
239
+ return None
240
+
241
+
242
+ def _get_xml_text(xml_root: _Element, xpath: str) -> str | None:
243
+ element = xml_root.find(xpath)
244
+ return None if element is None or element.text is None else element.text
225
245
 
226
246
 
227
247
  async def get_ec_sites():
@@ -231,7 +251,7 @@ async def get_ec_sites():
231
251
 
232
252
  async with ClientSession(raise_for_status=True) as session:
233
253
  response = await session.get(
234
- SITE_LIST_URL, headers={"User-Agent": USER_AGENT}, timeout=10
254
+ SITE_LIST_URL, headers={"User-Agent": USER_AGENT}, timeout=CLIENT_TIMEOUT
235
255
  )
236
256
  sites_csv_string = await response.text()
237
257
 
@@ -292,7 +312,7 @@ class ECWeather:
292
312
 
293
313
  self.language = kwargs["language"]
294
314
  self.max_data_age = kwargs["max_data_age"]
295
- self.metadata = {"attribution": ATTRIBUTION[self.language]}
315
+ self.metadata = MetaData(ATTRIBUTION[self.language])
296
316
  self.conditions = {}
297
317
  self.alerts = {}
298
318
  self.daily_forecasts = []
@@ -309,9 +329,29 @@ class ECWeather:
309
329
  self.lat = kwargs["coordinates"][0]
310
330
  self.lon = kwargs["coordinates"][1]
311
331
 
312
- async def update(self):
332
+ def handle_error(self, err: Exception | None, msg: str) -> None:
333
+ """
334
+ Handle an known error, returning previous results if they have not expired, or
335
+ raising an exception if previous results have expired. Set the last_update_error
336
+ to the error no matter what.
337
+
338
+ On returning previous results, bump the cache returned counter else clear the counter.
339
+ """
340
+ expiry = self.metadata.timestamp + timedelta(hours=self.max_data_age)
341
+ self.metadata.last_update_error = msg
342
+ if expiry > datetime.now(timezone.utc):
343
+ self.metadata.cache_returned_on_update += 1
344
+ return
345
+
346
+ self.metadata.cache_returned_on_update = 0
347
+ raise ECWeatherUpdateFailed(msg) from err
348
+
349
+ async def update(self) -> None:
313
350
  """Get the latest data from Environment Canada."""
314
351
 
352
+ # Clear error at start, any error that is handled will set it
353
+ self.metadata.last_update_error = ""
354
+
315
355
  # Determine station ID or coordinates if not provided
316
356
  if not self.site_list:
317
357
  self.site_list = await get_ec_sites()
@@ -335,42 +375,43 @@ class ECWeather:
335
375
  )
336
376
 
337
377
  # Get weather data
338
- async with ClientSession(raise_for_status=True) as session:
339
- response = await session.get(
340
- WEATHER_URL.format(self.station_id, self.language[0]),
341
- headers={"User-Agent": USER_AGENT},
342
- timeout=10,
378
+ try:
379
+ async with ClientSession(raise_for_status=True) as session:
380
+ response = await session.get(
381
+ WEATHER_URL.format(self.station_id, self.language[0]),
382
+ headers={"User-Agent": USER_AGENT},
383
+ timeout=CLIENT_TIMEOUT,
384
+ )
385
+ weather_xml = await response.text()
386
+ except (ClientConnectorDNSError, TimeoutError) as err:
387
+ return self.handle_error(err, f"Unable to retrieve weather: {err}")
388
+ except ClientResponseError as err:
389
+ return self.handle_error(
390
+ err,
391
+ f"Unable to retrieve weather '{err.request_info.url}': {err.message} ({err.status})",
343
392
  )
344
- result = await response.read()
345
- weather_xml = result
346
393
 
347
394
  try:
348
- weather_tree = et.fromstring(weather_xml)
395
+ weather_tree = et.fromstring(bytes(weather_xml, encoding="utf-8"))
349
396
  except et.ParseError as err:
350
- raise ECWeatherUpdateFailed(
351
- "Weather update failed; could not parse result"
352
- ) from err
353
-
354
- # Update metadata
355
- for m, meta in metadata_meta.items():
356
- element = weather_tree.find(meta["xpath"])
357
- if element is not None:
358
- self.metadata[m] = weather_tree.find(meta["xpath"]).text
359
- if m == "timestamp":
360
- self.metadata[m] = parse_timestamp(self.metadata[m])
361
- else:
362
- self.metadata[m] = None
363
-
364
- # Check data age
365
- if self.metadata["timestamp"] is None:
366
- raise ECWeatherUpdateFailed("Weather update failed; no timestamp found")
367
-
368
- max_age = datetime.datetime.now(
369
- datetime.timezone.utc
370
- ) - relativedelta.relativedelta(hours=self.max_data_age)
397
+ # Parse error happens when data return is malformed (truncated, possibly because of network error)
398
+ return self.handle_error(
399
+ err, f"Could not parse retrieved weather; length {len(weather_xml)}"
400
+ )
371
401
 
372
- if self.metadata["timestamp"] < max_age:
373
- raise ECWeatherUpdateFailed("Weather update failed; outdated data returned")
402
+ timestamp = _parse_timestamp(
403
+ _get_xml_text(weather_tree, "./dateTime/timeStamp")
404
+ )
405
+ if timestamp is None:
406
+ return self.handle_error(
407
+ None, "Timestamp not found in retrieved weather; response ignored"
408
+ )
409
+ expiry = timestamp + timedelta(hours=self.max_data_age)
410
+ if expiry < datetime.now(timezone.utc):
411
+ return self.handle_error(
412
+ None,
413
+ f"Outdated conditions returned from Environment Canada '{timestamp}'; not used",
414
+ )
374
415
 
375
416
  # Parse condition
376
417
  def get_condition(meta):
@@ -404,21 +445,17 @@ class ECWeather:
404
445
  elif meta["type"] == "str":
405
446
  condition["value"] = element.text
406
447
  elif meta["type"] == "timestamp":
407
- condition["value"] = parse_timestamp(element.text)
448
+ condition["value"] = _parse_timestamp(element.text)
408
449
 
409
450
  return condition
410
451
 
411
452
  # Update current conditions
412
- if len(weather_tree.find("./currentConditions")) > 0:
453
+ current_conditions = weather_tree.find("./currentConditions")
454
+ if current_conditions is not None and len(current_conditions) > 0:
413
455
  for c, meta in conditions_meta.items():
414
456
  self.conditions[c] = {"label": meta[self.language]}
415
457
  self.conditions[c].update(get_condition(meta))
416
458
 
417
- # Update station metadata
418
- self.metadata["station"] = weather_tree.find(
419
- "./currentConditions/station"
420
- ).text
421
-
422
459
  # Update text summary
423
460
  period = get_condition(summary_meta["forecast_period"])["value"]
424
461
  summary = get_condition(summary_meta["text_summary"])["value"]
@@ -429,67 +466,76 @@ class ECWeather:
429
466
  }
430
467
 
431
468
  # Update alerts
432
- for category, meta in alerts_meta.items():
433
- self.alerts[category] = {"value": [], "label": meta[self.language]["label"]}
434
-
469
+ self.alerts = ALERTS_INIT[self.language].copy()
435
470
  alert_elements = weather_tree.findall("./warnings/event")
436
-
437
- for a in alert_elements:
438
- title = a.attrib.get("description").strip()
439
- for category, meta in alerts_meta.items():
440
- category_match = re.search(meta[self.language]["pattern"], title)
441
- if category_match:
442
- alert = {
443
- "title": title.title(),
444
- "date": a.find("./dateTime[last()]/textSummary").text,
471
+ for alert in alert_elements:
472
+ title = alert.attrib.get("description")
473
+ type_ = alert.attrib.get("type")
474
+ if title is not None and type_ is not None and type_ in ALERT_TYPE_TO_NAME:
475
+ self.alerts[ALERT_TYPE_TO_NAME[type_]]["value"].append( # type: ignore[attr-defined]
476
+ {
477
+ "title": title.strip().title(),
478
+ "date": _get_xml_text(alert, "./dateTime[last()]/textSummary"),
445
479
  }
446
- self.alerts[category]["value"].append(alert)
480
+ )
447
481
 
448
482
  # Update forecasts
449
- self.forecast_time = parse_timestamp(
450
- weather_tree.findtext("./forecastGroup/dateTime/timeStamp")
483
+ self.forecast_time = _parse_timestamp(
484
+ _get_xml_text(weather_tree, "./forecastGroup/dateTime/timeStamp")
451
485
  )
452
486
  self.daily_forecasts = []
453
487
  self.hourly_forecasts = []
454
488
 
455
489
  # Update daily forecasts
456
- forecast_time = self.forecast_time
457
- for f in weather_tree.findall("./forecastGroup/forecast"):
458
- self.daily_forecasts.append(
459
- {
460
- "period": f.findtext("period"),
461
- "text_summary": f.findtext("textSummary"),
462
- "icon_code": f.findtext("./abbreviatedForecast/iconCode"),
463
- "temperature": int(f.findtext("./temperatures/temperature") or 0),
464
- "temperature_class": f.find(
465
- "./temperatures/temperature"
466
- ).attrib.get("class"),
467
- "precip_probability": int(
468
- f.findtext("./abbreviatedForecast/pop") or "0"
469
- ),
470
- "timestamp": forecast_time,
471
- }
472
- )
473
- if self.daily_forecasts[-1]["temperature_class"] == "low":
474
- forecast_time = forecast_time + datetime.timedelta(days=1)
490
+ if self.forecast_time is not None:
491
+ forecast_time = self.forecast_time
492
+ for f in weather_tree.findall("./forecastGroup/forecast"):
493
+ temperature_element = f.find("./temperatures/temperature")
494
+ self.daily_forecasts.append(
495
+ {
496
+ "period": f.findtext("period"),
497
+ "text_summary": f.findtext("textSummary"),
498
+ "icon_code": f.findtext("./abbreviatedForecast/iconCode"),
499
+ "temperature": int(
500
+ f.findtext("./temperatures/temperature") or 0
501
+ ),
502
+ "temperature_class": temperature_element.attrib.get("class")
503
+ if temperature_element is not None
504
+ else None,
505
+ "precip_probability": int(
506
+ f.findtext("./abbreviatedForecast/pop") or "0"
507
+ ),
508
+ "timestamp": forecast_time,
509
+ }
510
+ )
511
+ if self.daily_forecasts[-1]["temperature_class"] == "low":
512
+ forecast_time = forecast_time + timedelta(days=1)
475
513
 
476
514
  # Update hourly forecasts
477
515
  for f in weather_tree.findall("./hourlyForecastGroup/hourlyForecast"):
478
516
  wind_speed_text = f.findtext("./wind/speed")
479
517
  self.hourly_forecasts.append(
480
518
  {
481
- "period": parse_timestamp(f.attrib.get("dateTimeUTC")),
519
+ "period": _parse_timestamp(f.attrib.get("dateTimeUTC")),
482
520
  "condition": f.findtext("./condition"),
483
521
  "temperature": int(f.findtext("./temperature") or 0),
484
522
  "icon_code": f.findtext("./iconCode"),
485
523
  "precip_probability": int(f.findtext("./lop") or "0"),
486
- "wind_speed": int(
487
- wind_speed_text if wind_speed_text.isnumeric() else 0
488
- ),
524
+ "wind_speed": int(wind_speed_text)
525
+ if wind_speed_text and wind_speed_text.isnumeric()
526
+ else 0,
489
527
  "wind_direction": f.findtext("./wind/direction"),
490
528
  }
491
529
  )
492
530
 
531
+ # Update metadata at the end
532
+ self.metadata.cache_returned_on_update = 0
533
+ self.metadata.timestamp = timestamp
534
+ self.metadata.location = _get_xml_text(weather_tree, "./location/name")
535
+ self.metadata.station = _get_xml_text(
536
+ weather_tree, "./currentConditions/station"
537
+ )
538
+
493
539
 
494
540
  class ECWeatherUpdateFailed(Exception):
495
541
  """Raised when an update fails to get usable data."""
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: env_canada
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: A package to access meteorological data from Environment Canada
5
5
  Author-email: Michael Davie <michael.davie@gmail.com>
6
6
  Maintainer-email: Michael Davie <michael.davie@gmail.com>
@@ -27,25 +27,24 @@ License: Copyright (c) 2018 The Python Packaging Authority
27
27
  Project-URL: Homepage, https://github.com/michaeldavie/env_canada
28
28
  Project-URL: Documentation, https://github.com/michaeldavie/env_canada
29
29
  Project-URL: Repository, https://github.com/michaeldavie/env_canada
30
- Project-URL: Bug Tracker, https://github.com/michaeldavie/env_canada/issues
30
+ Project-URL: Issues, https://github.com/michaeldavie/env_canada/issues
31
31
  Project-URL: Changelog, https://github.com/michaeldavie/env_canada/blob/master/CHANGELOG.md
32
32
  Classifier: Programming Language :: Python :: 3
33
33
  Classifier: License :: OSI Approved :: MIT License
34
34
  Classifier: Operating System :: OS Independent
35
- Requires-Python: >=3.10
35
+ Requires-Python: >=3.11
36
36
  Description-Content-Type: text/markdown
37
37
  License-File: LICENSE
38
38
  Requires-Dist: aiohttp>=3.9.0
39
- Requires-Dist: geopy
39
+ Requires-Dist: geopy>=2.4.1
40
40
  Requires-Dist: imageio>=2.28.0
41
- Requires-Dist: lxml
41
+ Requires-Dist: lxml>=5.3.0
42
42
  Requires-Dist: numpy>=1.22.2
43
- Requires-Dist: pandas>=1.3.0
43
+ Requires-Dist: pandas>=2.2.3
44
44
  Requires-Dist: Pillow>=10.0.1
45
- Requires-Dist: python-dateutil
46
- Requires-Dist: voluptuous
47
- Provides-Extra: dev
48
- Requires-Dist: pytest; extra == "dev"
45
+ Requires-Dist: python-dateutil>=2.9
46
+ Requires-Dist: voluptuous>=0.15.2
47
+ Dynamic: license-file
49
48
 
50
49
  # Environment Canada (env_canada)
51
50
 
@@ -1,6 +1,6 @@
1
1
  env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
2
2
  env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
3
- env_canada/__init__.py,sha256=Qqtu4qEY1gvzPF4D0qzi8kVvtYUizJDzQk5FZVqoo38,314
3
+ env_canada/__init__.py,sha256=O1KVC2GAPTmxAP46FXo9jd8Tq3YMefViowGjuxJ5JJM,366
4
4
  env_canada/constants.py,sha256=RHa-Hp2H6XroFE9Z5-H0MIUPWlFXUVKmWEidwuWzakE,32
5
5
  env_canada/ec_aqhi.py,sha256=-N4XLLgApaWZvtepJvS2lvqDSNjwV2X_fkmbtKQv8rA,7741
6
6
  env_canada/ec_cache.py,sha256=zb3n79ul7hUTE0IohDfZbRBLY-siOHPjYzWldMbuPVk,798
@@ -8,9 +8,9 @@ env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
8
8
  env_canada/ec_historical.py,sha256=qMr4RE6vfNiNa_zFolQ0PQGraok8bQtIVjs_o6sJKD4,16276
9
9
  env_canada/ec_hydro.py,sha256=JoBe-QVV8GEeZXCNFscIs2R_spgkbxCZpLt7tL6-NUI,4889
10
10
  env_canada/ec_radar.py,sha256=0SKusJWDTFODdn3D9yrhlkOS-Bv9hhBJM9EBh8TNRlk,12965
11
- env_canada/ec_weather.py,sha256=PSgb67PODo8Lv7A7O01q97SjvHCK0lwpcDtqTa70GI8,17048
12
- env_canada-0.8.0.dist-info/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
13
- env_canada-0.8.0.dist-info/METADATA,sha256=U548dMi4w3eX8MqFQrQseJthoggUFfC_a1AefMYjemA,12723
14
- env_canada-0.8.0.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
15
- env_canada-0.8.0.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
- env_canada-0.8.0.dist-info/RECORD,,
11
+ env_canada/ec_weather.py,sha256=l-gyXePkHBLW9vidFgiPEm81xevbZCeBKzKDLiLr7Y8,19200
12
+ env_canada-0.9.0.dist-info/licenses/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
13
+ env_canada-0.9.0.dist-info/METADATA,sha256=divVpZpbuU3Ch7_l1hno2So0e3ePrPoDr9nLOg9_8To,12709
14
+ env_canada-0.9.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
15
+ env_canada-0.9.0.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
+ env_canada-0.9.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.7.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5