env-canada 0.7.2__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
@@ -1,5 +1,15 @@
1
+ __all__ = [
2
+ "ECAirQuality",
3
+ "ECHistorical",
4
+ "ECHistoricalRange",
5
+ "ECHydro",
6
+ "ECRadar",
7
+ "ECWeather",
8
+ "ECWeatherUpdateFailed",
9
+ ]
10
+
1
11
  from .ec_aqhi import ECAirQuality
2
12
  from .ec_historical import ECHistorical, ECHistoricalRange
3
13
  from .ec_hydro import ECHydro
4
14
  from .ec_radar import ECRadar
5
- from .ec_weather import ECWeather
15
+ from .ec_weather import ECWeather, ECWeatherUpdateFailed
env_canada/constants.py CHANGED
@@ -1 +1 @@
1
- USER_AGENT = "env_canada/0.7.2"
1
+ USER_AGENT = "env_canada/0.8.0"
env_canada/ec_aqhi.py CHANGED
@@ -1,10 +1,10 @@
1
- from datetime import datetime, timezone
2
1
  import logging
2
+ from datetime import datetime, timezone
3
3
 
4
+ import voluptuous as vol
4
5
  from aiohttp import ClientSession
5
- import defusedxml.ElementTree as et
6
6
  from geopy import distance
7
- import voluptuous as vol
7
+ from lxml import etree as et
8
8
 
9
9
  from .constants import USER_AGENT
10
10
 
@@ -43,7 +43,7 @@ async def get_aqhi_regions(language):
43
43
  )
44
44
  result = await response.read()
45
45
 
46
- site_xml = result.decode("utf-8")
46
+ site_xml = result
47
47
  xml_object = et.fromstring(site_xml)
48
48
 
49
49
  for zone in xml_object.findall("./EC_administrativeZone"):
@@ -83,7 +83,7 @@ async def find_closest_region(language, lat, lon):
83
83
  return min(region_list, key=site_distance)
84
84
 
85
85
 
86
- class ECAirQuality(object):
86
+ class ECAirQuality:
87
87
  """Get air quality data from Environment Canada."""
88
88
 
89
89
  def __init__(self, **kwargs):
@@ -152,7 +152,7 @@ class ECAirQuality(object):
152
152
  return None
153
153
 
154
154
  result = await response.read()
155
- aqhi_xml = result.decode("ISO-8859-1")
155
+ aqhi_xml = result
156
156
  return et.fromstring(aqhi_xml)
157
157
 
158
158
  async def update(self):
env_canada/ec_cache.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from datetime import datetime
2
+ from typing import Any, ClassVar
2
3
 
3
4
 
4
5
  class Cache:
5
- _cache = {}
6
+ _cache: ClassVar[dict[str, tuple[datetime, Any]]] = {}
6
7
 
7
8
  @classmethod
8
9
  def add(cls, cache_key, item, cache_time):
@@ -1,26 +1,28 @@
1
+ import asyncio
1
2
  import copy
2
3
  import csv
4
+ import logging
3
5
  from datetime import datetime
4
- from dateutil.relativedelta import relativedelta
5
6
  from io import StringIO
6
- import logging
7
- import xml.etree.ElementTree as et
8
- import pandas as pd
9
- import asyncio
10
7
 
11
- from aiohttp import ClientSession
12
- from dateutil import parser, tz
13
- import defusedxml.ElementTree as et
14
8
  import lxml.html
9
+ import pandas as pd
15
10
  import voluptuous as vol
11
+ from aiohttp import ClientSession
12
+ from dateutil import parser, tz
13
+ from dateutil.relativedelta import relativedelta
14
+ from lxml import etree as et
16
15
 
17
16
  from .constants import USER_AGENT
18
17
 
19
-
20
18
  STATIONS_URL = "https://climate.weather.gc.ca/historical_data/search_historic_data_stations_{}.html"
21
19
 
22
20
  WEATHER_URL = "https://climate.weather.gc.ca/climate_data/bulk_data_{}.html"
23
21
 
22
+ _TODAY = datetime.today().date()
23
+ _ONE_YEAR_AGO = _TODAY - relativedelta(years=1, months=1, day=1)
24
+ _YEAR = datetime.today().year
25
+
24
26
  LOG = logging.getLogger(__name__)
25
27
 
26
28
  __all__ = ["ECHistorical"]
@@ -126,7 +128,7 @@ async def get_historical_stations(
126
128
  coordinates,
127
129
  radius=25,
128
130
  start_year=1840,
129
- end_year=datetime.today().year,
131
+ end_year=_YEAR,
130
132
  limit=25,
131
133
  language="english",
132
134
  timeframe=2,
@@ -204,8 +206,7 @@ async def get_historical_stations(
204
206
  return stations
205
207
 
206
208
 
207
- class ECHistorical(object):
208
-
209
+ class ECHistorical:
209
210
  """Get historical weather data from Environment Canada."""
210
211
 
211
212
  def __init__(self, **kwargs):
@@ -353,30 +354,30 @@ def flip_daterange(f):
353
354
  return wrapper
354
355
 
355
356
 
356
- class ECHistoricalRange(object):
357
+ class ECHistoricalRange:
357
358
  """Get historical weather data from Environment Canada in the given range for the given station.
358
359
 
359
- options are daily or hourly data
360
+ options are daily or hourly data
360
361
 
361
- Example:
362
- import pandas as pd
363
- import asyncio
364
- from env_canada import ECHistoricalRange, get_historical_stations
365
- from datetime import datetime
362
+ Example:
363
+ import pandas as pd
364
+ import asyncio
365
+ from env_canada import ECHistoricalRange, get_historical_stations
366
+ from datetime import datetime
366
367
 
367
- coordinates = ['48.508333', '-68.467667']
368
+ coordinates = ['48.508333', '-68.467667']
368
369
 
369
- stations = pd.DataFrame(asyncio.run(get_historical_stations(coordinates, start_year=2022,
370
- end_year=2022, radius=200, limit=100))).T
370
+ stations = pd.DataFrame(asyncio.run(get_historical_stations(coordinates, start_year=2022,
371
+ end_year=2022, radius=200, limit=100))).T
371
372
 
372
- ec = ECHistoricalRange(station_id=int(stations.iloc[0,2]), timeframe="hourly",
373
- daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)))
373
+ ec = ECHistoricalRange(station_id=int(stations.iloc[0,2]), timeframe="hourly",
374
+ daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)))
374
375
 
375
- ec.get_data()
376
+ ec.get_data()
376
377
 
377
- ec.xml #yield an XML formatted str. For more options, use ec.to_xml(*arg, **kwargs) with pandas options
378
-
379
- ec.csv #yield an CSV formatted str. For more options, use ec.to_csv(*arg, **kwargs) with pandas options
378
+ ec.xml #yield an XML formatted str. For more options, use ec.to_xml(*arg, **kwargs) with pandas options
379
+ =
380
+ ec.csv #yield an CSV formatted str. For more options, use ec.to_csv(*arg, **kwargs) with pandas options
380
381
  """
381
382
 
382
383
  @flip_daterange
@@ -384,8 +385,8 @@ class ECHistoricalRange(object):
384
385
  self,
385
386
  station_id,
386
387
  daterange=(
387
- datetime.today().date() - relativedelta(years=1, months=1, day=1),
388
- datetime.today().date(),
388
+ _ONE_YEAR_AGO,
389
+ _TODAY,
389
390
  ),
390
391
  language="english",
391
392
  timeframe="daily",
@@ -409,7 +410,14 @@ class ECHistoricalRange(object):
409
410
  self.months = self.monthlist(daterange=daterange)
410
411
  self.language = language
411
412
  _tf = {"hourly": 1, "daily": 2, "monthly": 3}
412
- self.timeframe = _tf[timeframe]
413
+ timeframe_int = _tf[timeframe]
414
+ if timeframe_int == 2:
415
+ # prune the months list so it only has unique years. if daily is selected.
416
+ years = set()
417
+ for year, _ in self.months:
418
+ years.add(year)
419
+ self.months = [(year, 1) for year in years]
420
+ self.timeframe = timeframe_int
413
421
 
414
422
  def get_data(self):
415
423
  """
@@ -419,7 +427,6 @@ class ECHistoricalRange(object):
419
427
  """
420
428
  if not self.df.empty:
421
429
  self.df = pd.DataFrame()
422
-
423
430
  ec = [
424
431
  ECHistorical(
425
432
  station_id=self.station_id,
@@ -437,7 +444,7 @@ class ECHistoricalRange(object):
437
444
  self.df = pd.concat((self.df, pd.read_csv(data.station_data)))
438
445
 
439
446
  self.df = self.df.set_index(
440
- self.df.filter(regex="Date/*", axis=1).columns.values[0]
447
+ self.df.filter(regex="Date/*", axis=1).columns.to_numpy()[0]
441
448
  )
442
449
  self.df.index = pd.to_datetime(self.df.index)
443
450
 
@@ -477,7 +484,10 @@ class ECHistoricalRange(object):
477
484
  @flip_daterange
478
485
  def monthlist(self, daterange):
479
486
  startdate, stopdate = daterange
480
- total_months = lambda dt: dt.month + 12 * dt.year
487
+
488
+ def total_months(dt):
489
+ return dt.month + 12 * dt.year
490
+
481
491
  mlist = []
482
492
  for tot_m in range(total_months(startdate) - 1, total_months(stopdate)):
483
493
  y, m = divmod(tot_m, 12)
env_canada/ec_hydro.py CHANGED
@@ -1,10 +1,10 @@
1
1
  import csv
2
2
  import io
3
3
 
4
+ import voluptuous as vol
4
5
  from aiohttp import ClientSession
5
6
  from dateutil.parser import isoparse
6
7
  from geopy import distance
7
- import voluptuous as vol
8
8
 
9
9
  from .constants import USER_AGENT
10
10
 
@@ -54,8 +54,7 @@ async def closest_site(lat, lon):
54
54
  return closest
55
55
 
56
56
 
57
- class ECHydro(object):
58
-
57
+ class ECHydro:
59
58
  """Get hydrometric data from Environment Canada."""
60
59
 
61
60
  def __init__(self, **kwargs):
env_canada/ec_radar.py CHANGED
@@ -1,16 +1,16 @@
1
1
  import asyncio
2
- from datetime import date, timedelta
3
2
  import logging
4
3
  import math
5
4
  import os
5
+ from datetime import date, timedelta
6
6
  from io import BytesIO
7
7
  from typing import cast
8
8
 
9
9
  import dateutil.parser
10
- import defusedxml.ElementTree as et
11
10
  import voluptuous as vol
12
11
  from aiohttp import ClientSession
13
12
  from aiohttp.client_exceptions import ClientConnectorError
13
+ from lxml import etree as et
14
14
  from PIL import Image, ImageDraw, ImageFont
15
15
 
16
16
  from .constants import USER_AGENT
@@ -114,7 +114,7 @@ async def _get_resource(url, params, bytes=True):
114
114
  return await response.text()
115
115
 
116
116
 
117
- class ECRadar(object):
117
+ class ECRadar:
118
118
  def __init__(self, **kwargs):
119
119
  """Initialize the radar object."""
120
120
 
@@ -195,7 +195,7 @@ class ECRadar(object):
195
195
  return Cache.add("basemap", base_bytes, timedelta(days=7))
196
196
 
197
197
  except ClientConnectorError as e:
198
- logging.warning("Map from %s could not be retrieved: %s" % map_url, e)
198
+ logging.warning("Map from %s could not be retrieved: %s", map_url, e)
199
199
 
200
200
  async def _get_legend(self):
201
201
  """Fetch legend image."""
@@ -226,7 +226,7 @@ class ECRadar(object):
226
226
  if not (capabilities_xml := Cache.get(capabilities_cache_key)):
227
227
  capabilities_params["layer"] = precip_layers[self._precip_type_actual]
228
228
  capabilities_xml = await _get_resource(
229
- geomet_url, capabilities_params, bytes=False
229
+ geomet_url, capabilities_params, bytes=True
230
230
  )
231
231
  Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
232
232
 
@@ -236,9 +236,9 @@ class ECRadar(object):
236
236
  )
237
237
  if dimension_string is not None:
238
238
  if dimension_string := dimension_string.text:
239
- start, end = [
239
+ start, end = (
240
240
  dateutil.parser.isoparse(t) for t in dimension_string.split("/")[:2]
241
- ]
241
+ )
242
242
  self.timestamp = end.isoformat()
243
243
  return (start, end)
244
244
  return None
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
-
6
- from aiohttp import ClientSession
7
- from dateutil import parser, relativedelta, tz
8
- import defusedxml.ElementTree as et
9
- from geopy import distance
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
10
6
  import voluptuous as vol
7
+ from aiohttp import (
8
+ ClientConnectorDNSError,
9
+ ClientResponseError,
10
+ ClientSession,
11
+ ClientTimeout,
12
+ )
13
+ from dateutil import parser, tz
14
+ from geopy import distance
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É).)*$"},
193
- },
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É).)*$"},
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": []},
201
207
  },
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
 
@@ -259,7 +279,7 @@ def closest_site(site_list, lat, lon):
259
279
  return "{}/{}".format(closest["Province Codes"], closest["Codes"])
260
280
 
261
281
 
262
- class ECWeather(object):
282
+ class ECWeather:
263
283
  """Get weather data from Environment Canada."""
264
284
 
265
285
  def __init__(self, **kwargs):
@@ -292,7 +312,7 @@ class ECWeather(object):
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(object):
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,40 +375,43 @@ class ECWeather(object):
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.decode()
346
393
 
347
394
  try:
348
- weather_tree = et.fromstring(weather_xml)
349
- except et.ParseError:
350
- raise ECWeatherUpdateFailed("Weather update failed; could not parse result")
351
-
352
- # Update metadata
353
- for m, meta in metadata_meta.items():
354
- element = weather_tree.find(meta["xpath"])
355
- if element is not None:
356
- self.metadata[m] = weather_tree.find(meta["xpath"]).text
357
- if m == "timestamp":
358
- self.metadata[m] = parse_timestamp(self.metadata[m])
359
- else:
360
- self.metadata[m] = None
361
-
362
- # Check data age
363
- if self.metadata["timestamp"] is None:
364
- raise ECWeatherUpdateFailed("Weather update failed; no timestamp found")
365
-
366
- max_age = datetime.datetime.now(
367
- datetime.timezone.utc
368
- ) - relativedelta.relativedelta(hours=self.max_data_age)
395
+ weather_tree = et.fromstring(bytes(weather_xml, encoding="utf-8"))
396
+ except et.ParseError as err:
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
+ )
369
401
 
370
- if self.metadata["timestamp"] < max_age:
371
- 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
+ )
372
415
 
373
416
  # Parse condition
374
417
  def get_condition(meta):
@@ -402,21 +445,17 @@ class ECWeather(object):
402
445
  elif meta["type"] == "str":
403
446
  condition["value"] = element.text
404
447
  elif meta["type"] == "timestamp":
405
- condition["value"] = parse_timestamp(element.text)
448
+ condition["value"] = _parse_timestamp(element.text)
406
449
 
407
450
  return condition
408
451
 
409
452
  # Update current conditions
410
- 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:
411
455
  for c, meta in conditions_meta.items():
412
456
  self.conditions[c] = {"label": meta[self.language]}
413
457
  self.conditions[c].update(get_condition(meta))
414
458
 
415
- # Update station metadata
416
- self.metadata["station"] = weather_tree.find(
417
- "./currentConditions/station"
418
- ).text
419
-
420
459
  # Update text summary
421
460
  period = get_condition(summary_meta["forecast_period"])["value"]
422
461
  summary = get_condition(summary_meta["text_summary"])["value"]
@@ -427,67 +466,76 @@ class ECWeather(object):
427
466
  }
428
467
 
429
468
  # Update alerts
430
- for category, meta in alerts_meta.items():
431
- self.alerts[category] = {"value": [], "label": meta[self.language]["label"]}
432
-
469
+ self.alerts = ALERTS_INIT[self.language].copy()
433
470
  alert_elements = weather_tree.findall("./warnings/event")
434
-
435
- for a in alert_elements:
436
- title = a.attrib.get("description").strip()
437
- for category, meta in alerts_meta.items():
438
- category_match = re.search(meta[self.language]["pattern"], title)
439
- if category_match:
440
- alert = {
441
- "title": title.title(),
442
- "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"),
443
479
  }
444
- self.alerts[category]["value"].append(alert)
480
+ )
445
481
 
446
482
  # Update forecasts
447
- self.forecast_time = parse_timestamp(
448
- weather_tree.findtext("./forecastGroup/dateTime/timeStamp")
483
+ self.forecast_time = _parse_timestamp(
484
+ _get_xml_text(weather_tree, "./forecastGroup/dateTime/timeStamp")
449
485
  )
450
486
  self.daily_forecasts = []
451
487
  self.hourly_forecasts = []
452
488
 
453
489
  # Update daily forecasts
454
- forecast_time = self.forecast_time
455
- for f in weather_tree.findall("./forecastGroup/forecast"):
456
- self.daily_forecasts.append(
457
- {
458
- "period": f.findtext("period"),
459
- "text_summary": f.findtext("textSummary"),
460
- "icon_code": f.findtext("./abbreviatedForecast/iconCode"),
461
- "temperature": int(f.findtext("./temperatures/temperature") or 0),
462
- "temperature_class": f.find(
463
- "./temperatures/temperature"
464
- ).attrib.get("class"),
465
- "precip_probability": int(
466
- f.findtext("./abbreviatedForecast/pop") or "0"
467
- ),
468
- "timestamp": forecast_time,
469
- }
470
- )
471
- if self.daily_forecasts[-1]["temperature_class"] == "low":
472
- 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)
473
513
 
474
514
  # Update hourly forecasts
475
515
  for f in weather_tree.findall("./hourlyForecastGroup/hourlyForecast"):
476
516
  wind_speed_text = f.findtext("./wind/speed")
477
517
  self.hourly_forecasts.append(
478
518
  {
479
- "period": parse_timestamp(f.attrib.get("dateTimeUTC")),
519
+ "period": _parse_timestamp(f.attrib.get("dateTimeUTC")),
480
520
  "condition": f.findtext("./condition"),
481
521
  "temperature": int(f.findtext("./temperature") or 0),
482
522
  "icon_code": f.findtext("./iconCode"),
483
523
  "precip_probability": int(f.findtext("./lop") or "0"),
484
- "wind_speed": int(
485
- wind_speed_text if wind_speed_text.isnumeric() else 0
486
- ),
524
+ "wind_speed": int(wind_speed_text)
525
+ if wind_speed_text and wind_speed_text.isnumeric()
526
+ else 0,
487
527
  "wind_direction": f.findtext("./wind/direction"),
488
528
  }
489
529
  )
490
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
+
491
539
 
492
540
  class ECWeatherUpdateFailed(Exception):
493
541
  """Raised when an update fails to get usable data."""
@@ -1,34 +1,72 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: env_canada
3
- Version: 0.7.2
3
+ Version: 0.9.0
4
4
  Summary: A package to access meteorological data from Environment Canada
5
- Home-page: https://github.com/michaeldavie/env_canada
6
- Author: Michael Davie
7
- Author-email: michael.davie@gmail.com
8
- License: MIT
5
+ Author-email: Michael Davie <michael.davie@gmail.com>
6
+ Maintainer-email: Michael Davie <michael.davie@gmail.com>
7
+ License: Copyright (c) 2018 The Python Packaging Authority
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE
26
+
27
+ Project-URL: Homepage, https://github.com/michaeldavie/env_canada
28
+ Project-URL: Documentation, https://github.com/michaeldavie/env_canada
29
+ Project-URL: Repository, https://github.com/michaeldavie/env_canada
30
+ Project-URL: Issues, https://github.com/michaeldavie/env_canada/issues
31
+ Project-URL: Changelog, https://github.com/michaeldavie/env_canada/blob/master/CHANGELOG.md
9
32
  Classifier: Programming Language :: Python :: 3
10
33
  Classifier: License :: OSI Approved :: MIT License
11
34
  Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.11
12
36
  Description-Content-Type: text/markdown
13
37
  License-File: LICENSE
14
- Requires-Dist: aiohttp >=3.9.0
15
- Requires-Dist: defusedxml
16
- Requires-Dist: geopy
17
- Requires-Dist: imageio >=2.28.0
18
- Requires-Dist: lxml
19
- Requires-Dist: numpy >=1.22.2
20
- Requires-Dist: pandas >=1.3.0
21
- Requires-Dist: Pillow >=10.0.1
22
- Requires-Dist: python-dateutil
23
- Requires-Dist: voluptuous
38
+ Requires-Dist: aiohttp>=3.9.0
39
+ Requires-Dist: geopy>=2.4.1
40
+ Requires-Dist: imageio>=2.28.0
41
+ Requires-Dist: lxml>=5.3.0
42
+ Requires-Dist: numpy>=1.22.2
43
+ Requires-Dist: pandas>=2.2.3
44
+ Requires-Dist: Pillow>=10.0.1
45
+ Requires-Dist: python-dateutil>=2.9
46
+ Requires-Dist: voluptuous>=0.15.2
47
+ Dynamic: license-file
24
48
 
25
49
  # Environment Canada (env_canada)
26
50
 
27
51
  [![PyPI version](https://badge.fury.io/py/env-canada.svg)](https://badge.fury.io/py/env-canada)
28
- [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](https://snyk.io/vuln/pip:env-canada@0.7.2?utm_source=badge)
52
+ [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](https://snyk.io/vuln/pip:env-canada@0.8.0?utm_source=badge)
53
+ [![Python Lint and Test](../..//actions/workflows/python-app.yml/badge.svg)](../../actions/workflows/python-app.yml)
29
54
 
30
55
  This package provides access to various data sources published by [Environment and Climate Change Canada](https://www.canada.ca/en/environment-climate-change.html).
31
56
 
57
+ > [!IMPORTANT]
58
+ > If you're using the library in a Jupyter notebook, replace `asyncio.run(...)` with `await ...` in the examples below. For example:
59
+ >
60
+ > ```python
61
+ > asyncio.run(ec_en.update())
62
+ > ```
63
+ >
64
+ > becomes
65
+ >
66
+ > ```python
67
+ > await ec_en.update()
68
+ > ```
69
+
32
70
  ## Weather Observations and Forecasts
33
71
 
34
72
  `ECWeather` provides current conditions and forecasts. It automatically determines which weather station to use based on latitude/longitude provided. It is also possible to specify a specific station code of the form `AB/s0000123` based on those listed in [this CSV file](https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv). For example:
@@ -39,7 +77,7 @@ import asyncio
39
77
  from env_canada import ECWeather
40
78
 
41
79
  ec_en = ECWeather(coordinates=(50, -100))
42
- ec_fr = ECWeather(station_id='ON/s0000430', language='french')
80
+ ec_fr = ECWeather(station_id="ON/s0000430", language="french")
43
81
 
44
82
  asyncio.run(ec_en.update())
45
83
 
@@ -120,7 +158,7 @@ from env_canada import ECHistorical
120
158
  from env_canada.ec_historical import get_historical_stations
121
159
 
122
160
  # search for stations, response contains station_ids
123
- coordinates = [53.916944, -122.749444] # [lat, long]
161
+ coordinates = [53.916944, -122.749444] # [lat, long]
124
162
 
125
163
  # coordinates: [lat, long]
126
164
  # radius: km
@@ -136,8 +174,12 @@ ec_fr_csv = ECHistorical(station_id=31688, year=2020, language="french", format=
136
174
  # timeframe argument can be passed to change the granularity
137
175
  # timeframe=1 hourly (need to create of for every month in that case, use ECHistoricalRange to handle it automatically)
138
176
  # timeframe=2 daily (default)
139
- ec_en_xml = ECHistorical(station_id=31688, year=2020, month=1, language="english", format="xml", timeframe=1)
140
- ec_en_csv = ECHistorical(station_id=31688, year=2020, month=1, language="english", format="csv", timeframe=1)
177
+ ec_en_xml = ECHistorical(
178
+ station_id=31688, year=2020, month=1, language="english", format="xml", timeframe=1
179
+ )
180
+ ec_en_csv = ECHistorical(
181
+ station_id=31688, year=2020, month=1, language="english", format="csv", timeframe=1
182
+ )
141
183
 
142
184
  asyncio.run(ec_en_xml.update())
143
185
  asyncio.run(ec_en_csv.update())
@@ -150,8 +192,8 @@ ec_en_xml.station_data
150
192
 
151
193
  # csv-generated responses return csv-like station data
152
194
  import pandas as pd
153
- df = pd.read_csv(ec_en_csv.station_data)
154
195
 
196
+ df = pd.read_csv(ec_en_csv.station_data)
155
197
  ```
156
198
 
157
199
  `ECHistoricalRange` provides historical weather data within a specific range and handles the update by itself.
@@ -170,21 +212,29 @@ from env_canada import ECHistoricalRange
170
212
  from env_canada.ec_historical import get_historical_stations
171
213
  from datetime import datetime
172
214
 
173
- coordinates = ['48.508333', '-68.467667']
215
+ coordinates = ["48.508333", "-68.467667"]
174
216
 
175
- stations = pd.DataFrame(asyncio.run(get_historical_stations(coordinates, start_year=2022,
176
- end_year=2022, radius=200, limit=100))).T
217
+ stations = pd.DataFrame(
218
+ asyncio.run(
219
+ get_historical_stations(
220
+ coordinates, start_year=2022, end_year=2022, radius=200, limit=100
221
+ )
222
+ )
223
+ ).T
177
224
 
178
- ec = ECHistoricalRange(station_id=int(stations.iloc[0,2]), timeframe="daily",
179
- daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)))
225
+ ec = ECHistoricalRange(
226
+ station_id=int(stations.iloc[0, 2]),
227
+ timeframe="daily",
228
+ daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)),
229
+ )
180
230
 
181
231
  ec.get_data()
182
232
 
183
- #yield an XML formated str.
233
+ # yield an XML formated str.
184
234
  # For more options, use ec.to_xml(*arg, **kwargs) with pandas options
185
235
  ec.xml
186
236
 
187
- #yield an CSV formated str.
237
+ # yield an CSV formated str.
188
238
  # For more options, use ec.to_csv(*arg, **kwargs) with pandas options
189
239
  ec.csv
190
240
  ```
@@ -0,0 +1,16 @@
1
+ env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
2
+ env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
3
+ env_canada/__init__.py,sha256=O1KVC2GAPTmxAP46FXo9jd8Tq3YMefViowGjuxJ5JJM,366
4
+ env_canada/constants.py,sha256=RHa-Hp2H6XroFE9Z5-H0MIUPWlFXUVKmWEidwuWzakE,32
5
+ env_canada/ec_aqhi.py,sha256=-N4XLLgApaWZvtepJvS2lvqDSNjwV2X_fkmbtKQv8rA,7741
6
+ env_canada/ec_cache.py,sha256=zb3n79ul7hUTE0IohDfZbRBLY-siOHPjYzWldMbuPVk,798
7
+ env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
8
+ env_canada/ec_historical.py,sha256=qMr4RE6vfNiNa_zFolQ0PQGraok8bQtIVjs_o6sJKD4,16276
9
+ env_canada/ec_hydro.py,sha256=JoBe-QVV8GEeZXCNFscIs2R_spgkbxCZpLt7tL6-NUI,4889
10
+ env_canada/ec_radar.py,sha256=0SKusJWDTFODdn3D9yrhlkOS-Bv9hhBJM9EBh8TNRlk,12965
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 (70.3.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -16,4 +16,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
16
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
17
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
18
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
- SOFTWARE
19
+ SOFTWARE
@@ -1,16 +0,0 @@
1
- env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
2
- env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
3
- env_canada/__init__.py,sha256=wEx1BCwVUH__GoosSlhNMHuUKCKNZAvv5uuSa5ZWq_g,187
4
- env_canada/constants.py,sha256=DlW7o9XEpZNQry99jZ1vF0wX7Xm7ZRcK41n0YAwzp9U,32
5
- env_canada/ec_aqhi.py,sha256=zEEt2U8gCxaLlePexl23r9zCfQYgmfhsP0ur2ZiupZc,7793
6
- env_canada/ec_cache.py,sha256=xPlXBRLyrD6dTJWLRBy12J8kzBxMUC-20-xRuc56Hts,722
7
- env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
8
- env_canada/ec_historical.py,sha256=slHaFwsoyW16uCVtE3_-IF3_BFhFD4IuWl7rpIRsCm4,15901
9
- env_canada/ec_hydro.py,sha256=LBsWreTlaTKec6ObjI0ih8-zOKBNjD02oiXKTyUa1EQ,4898
10
- env_canada/ec_radar.py,sha256=zh0tbazBbvLpuxrY0yfRm9EIaXNkM6HXPe1us99h4xM,12982
11
- env_canada/ec_weather.py,sha256=cLWMTuvASrxYeGnXvGwsNZxd_SfD1sdfInBU51QLFCo,17026
12
- env_canada-0.7.2.dist-info/LICENSE,sha256=c037dTHQWAgRgDqZNN-5d-CZvcteSYN37u39SNklO0I,1072
13
- env_canada-0.7.2.dist-info/METADATA,sha256=M-fb4_i9Je1a2e6T5M7RIYDW33nknvWN6GHSKg60Sds,10707
14
- env_canada-0.7.2.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
15
- env_canada-0.7.2.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
- env_canada-0.7.2.dist-info/RECORD,,