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 +2 -1
- env_canada/ec_weather.py +157 -111
- {env_canada-0.8.0.dist-info → env_canada-0.9.0.dist-info}/METADATA +10 -11
- {env_canada-0.8.0.dist-info → env_canada-0.9.0.dist-info}/RECORD +7 -7
- {env_canada-0.8.0.dist-info → env_canada-0.9.0.dist-info}/WHEEL +1 -1
- {env_canada-0.8.0.dist-info → env_canada-0.9.0.dist-info/licenses}/LICENSE +0 -0
- {env_canada-0.8.0.dist-info → env_canada-0.9.0.dist-info}/top_level.txt +0 -0
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
|
8
|
-
|
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
|
-
|
183
|
-
"
|
184
|
-
"
|
185
|
-
"
|
186
|
-
|
187
|
-
|
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
|
-
"
|
195
|
-
"
|
196
|
-
"
|
197
|
-
|
198
|
-
|
199
|
-
"
|
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
|
-
|
209
|
-
|
210
|
-
"
|
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
|
224
|
-
|
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=
|
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 =
|
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
|
-
|
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
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
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
|
-
|
351
|
-
|
352
|
-
|
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
|
-
|
373
|
-
|
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"] =
|
448
|
+
condition["value"] = _parse_timestamp(element.text)
|
408
449
|
|
409
450
|
return condition
|
410
451
|
|
411
452
|
# Update current conditions
|
412
|
-
|
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
|
-
|
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
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
"
|
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
|
-
|
480
|
+
)
|
447
481
|
|
448
482
|
# Update forecasts
|
449
|
-
self.forecast_time =
|
450
|
-
weather_tree
|
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
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
"
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
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":
|
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
|
-
|
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
|
+
Metadata-Version: 2.4
|
2
2
|
Name: env_canada
|
3
|
-
Version: 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:
|
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.
|
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>=
|
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
|
-
|
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=
|
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=
|
12
|
-
env_canada-0.
|
13
|
-
env_canada-0.
|
14
|
-
env_canada-0.
|
15
|
-
env_canada-0.
|
16
|
-
env_canada-0.
|
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,,
|
File without changes
|
File without changes
|