simple-dwd-weatherforecast 2.1.1__py3-none-any.whl → 2.1.2__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.
@@ -7,6 +7,8 @@ from datetime import datetime, timedelta, timezone
7
7
  from enum import Enum
8
8
  from io import BytesIO
9
9
  from zipfile import ZipFile
10
+ import httpx
11
+ from stream_unzip import stream_unzip
10
12
 
11
13
  import arrow
12
14
  import requests
@@ -677,7 +679,7 @@ class Weather:
677
679
 
678
680
  items = []
679
681
  result = kmlTree.xpath(
680
- './kml:ExtendedData/dwd:Forecast[@dwd:elementName="{}"]/dwd:value'.format(
682
+ './kml:Document/kml:Placemark/kml:ExtendedData/dwd:Forecast[@dwd:elementName="{}"]/dwd:value'.format(
681
683
  weatherDataType.value[0]
682
684
  ),
683
685
  namespaces=self.namespaces,
@@ -694,14 +696,11 @@ class Weather:
694
696
  items.append(None)
695
697
  return items
696
698
 
697
- def parse_kml(self, kml, force_hourly=False):
698
- stream = etree.iterparse(kml)
699
- (_, tree) = next(stream)
699
+ def parse_kml(self, kml):
700
+ tree = etree.fromstring(kml) # type: ignore
700
701
  timesteps = self.parse_timesteps(tree)
701
702
  issue_time_new = self.parse_issue_time(tree)
702
- tree.clear()
703
703
 
704
- tree = self.parse_placemark(stream)
705
704
  self.issue_time = issue_time_new
706
705
 
707
706
  self.loaded_station_name = self.parse_station_name(tree)
@@ -748,17 +747,6 @@ class Weather:
748
747
  for (i, t) in enumerate(timesteps)
749
748
  )
750
749
 
751
- def parse_placemark(self, stream):
752
- for _, tree in stream:
753
- for placemark in tree.findall(
754
- ".//kml:Placemark", namespaces=self.namespaces
755
- ):
756
- item = placemark.find(".//kml:name", namespaces=self.namespaces)
757
-
758
- if item is not None and item.text == self.station_id:
759
- return placemark
760
- # placemark.clear()
761
-
762
750
  def parse_issue_time(self, tree):
763
751
  issue_time_new = arrow.get(
764
752
  tree.xpath("//dwd:IssueTime", namespaces=self.namespaces)[0].text,
@@ -767,7 +755,9 @@ class Weather:
767
755
  return issue_time_new
768
756
 
769
757
  def parse_station_name(self, tree):
770
- return tree.xpath("./kml:description", namespaces=self.namespaces)[0].text
758
+ return tree.xpath(
759
+ "./kml:Document/kml:Placemark/kml:description", namespaces=self.namespaces
760
+ )[0].text
771
761
 
772
762
  def parse_timesteps(self, tree):
773
763
  return [
@@ -781,7 +771,7 @@ class Weather:
781
771
  return [
782
772
  elem.split(".")[0]
783
773
  for elem in tree.xpath(
784
- './kml:ExtendedData/dwd:Forecast[@dwd:elementName="ww"]/dwd:value',
774
+ './kml:Document/kml:Placemark/kml:ExtendedData/dwd:Forecast[@dwd:elementName="ww"]/dwd:value',
785
775
  namespaces=self.namespaces,
786
776
  )[0].text.split()
787
777
  ]
@@ -945,11 +935,8 @@ class Weather:
945
935
  except Exception as error:
946
936
  print(f"Error in download_weather_report: {type(error)} args: {error.args}")
947
937
 
948
- def download_latest_kml(self, stationid, force_hourly=False):
949
- if force_hourly:
950
- url = "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz"
951
- else:
952
- url = f"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{stationid}/kml/MOSMIX_L_LATEST_{stationid}.kmz"
938
+ def download_small_kml(self, stationid) -> bytes | None:
939
+ url = f"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{stationid}/kml/MOSMIX_L_LATEST_{stationid}.kmz"
953
940
  headers = {"If-None-Match": self.etags[url] if url in self.etags else ""} # type: ignore
954
941
  try:
955
942
  request = requests.get(url, headers=headers, timeout=30)
@@ -958,12 +945,73 @@ class Weather:
958
945
  return
959
946
  self.etags[url] = request.headers["ETag"] # type: ignore
960
947
  with ZipFile(BytesIO(request.content), "r") as kmz:
961
- # large RAM allocation
962
948
  with kmz.open(kmz.namelist()[0], "r") as kml:
963
- self.parse_kml(kml, force_hourly)
949
+ return kml.read()
950
+
964
951
  except Exception as error:
965
952
  print(f"Error in download_latest_kml: {type(error)} args: {error.args}")
966
953
 
954
+ def get_chunks(self):
955
+ def zipped_chunks():
956
+ # Iterable that yields the bytes of a zip file
957
+ with httpx.stream(
958
+ "GET",
959
+ "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/all_stations/kml/MOSMIX_S_LATEST_240.kmz",
960
+ ) as r:
961
+ yield from r.iter_bytes(chunk_size=131072)
962
+
963
+ return stream_unzip(zipped_chunks())
964
+
965
+ def download_large_kml(self, stationid):
966
+ placemark = b""
967
+
968
+ for file_name, file_size, unzipped_chunks in self.get_chunks():
969
+ chunk1 = b""
970
+ chunk2 = b""
971
+ first_chunk = None
972
+
973
+ save_next = False
974
+ save_next_next = False
975
+ stop = False
976
+ # unzipped_chunks must be iterated to completion or UnfinishedIterationError will be raised
977
+ for chunk in unzipped_chunks:
978
+ if stop:
979
+ continue
980
+ if not first_chunk:
981
+ first_chunk = chunk
982
+ if save_next_next:
983
+ placemark = chunk1 + chunk2 + chunk
984
+ save_next_next = False
985
+ stop = True
986
+ if save_next:
987
+ chunk2 = chunk
988
+ save_next_next = True
989
+ save_next = False
990
+
991
+ if stationid.encode() in chunk:
992
+ chunk1 = chunk
993
+ save_next = True
994
+ if first_chunk:
995
+ start = placemark.find(b"<kml:Placemark>\n")
996
+
997
+ result = (
998
+ first_chunk[: first_chunk.find(b"<kml:Placemark>")]
999
+ + placemark[
1000
+ start : placemark.find(b"</kml:Placemark>\n", start) + 17
1001
+ ]
1002
+ + b"</kml:Document></kml:kml>"
1003
+ )
1004
+ return result
1005
+
1006
+ def download_latest_kml(self, stationid, force_hourly=False):
1007
+ kml = (
1008
+ self.download_large_kml(stationid)
1009
+ if force_hourly
1010
+ else self.download_small_kml(stationid)
1011
+ )
1012
+ if kml is not None:
1013
+ self.parse_kml(kml)
1014
+
967
1015
  def download_latest_report(self):
968
1016
  station_id = self.station_id
969
1017
  if len(station_id) == 4:
@@ -95,7 +95,15 @@ def get_map(
95
95
  raise ValueError(
96
96
  "Width and height must not exceed 1200 and 1400 respectively. Please be kind to the DWD servers."
97
97
  )
98
- url = f"https://maps.dwd.de/geoserver/dwd/wms?service=WMS&version=1.1.0&request=GetMap&layers={map_type.value},{background_type.value}&bbox={minx},{miny},{maxx},{maxy}&width={image_width}&height={image_height}&srs=EPSG:4326&styles=&format=image/png"
98
+ if background_type in [
99
+ WeatherBackgroundMapType.SATELLIT,
100
+ WeatherBackgroundMapType.KREISE,
101
+ WeatherBackgroundMapType.GEMEINDEN,
102
+ ]:
103
+ layers = f"{background_type.value}, {map_type.value}"
104
+ else:
105
+ layers = f"{map_type.value}, {background_type.value}"
106
+ url = f"https://maps.dwd.de/geoserver/dwd/wms?service=WMS&version=1.1.0&request=GetMap&layers={layers}&bbox={minx},{miny},{maxx},{maxy}&width={image_width}&height={image_height}&srs=EPSG:4326&styles=&format=image/png"
99
107
  request = requests.get(url, stream=True)
100
108
  if request.status_code == 200:
101
109
  image = Image.open(BytesIO(request.content))
@@ -135,6 +143,8 @@ class ImageLoop:
135
143
  self._miny = miny
136
144
  self._maxx = maxx
137
145
  self._maxy = maxy
146
+ if map_type != WeatherMapType.NIEDERSCHLAGSRADAR:
147
+ raise ValueError("Only NIEDERSCHLAGSRADAR is supported in a loop")
138
148
  self._map_type = map_type
139
149
  self._background_type = background_type
140
150
  self._steps = steps
@@ -173,7 +183,15 @@ class ImageLoop:
173
183
  self._images.append(self._get_image(self._last_update))
174
184
 
175
185
  def _get_image(self, date: datetime) -> ImageFile.ImageFile:
176
- url = f"https://maps.dwd.de/geoserver/dwd/wms?service=WMS&version=1.1.0&request=GetMap&layers={self._map_type.value},{self._background_type.value}&bbox={self._minx},{self._miny},{self._maxx},{self._maxy}&width={self._image_width}&height={self._image_height}&srs=EPSG:4326&styles=&format=image/png&TIME={date.strftime("%Y-%m-%dT%H:%M:00.0Z")}"
186
+ if self._background_type in [
187
+ WeatherBackgroundMapType.SATELLIT,
188
+ WeatherBackgroundMapType.KREISE,
189
+ WeatherBackgroundMapType.GEMEINDEN,
190
+ ]:
191
+ layers = f"{self._background_type.value}, {self._map_type.value}"
192
+ else:
193
+ layers = f"{self._map_type.value}, {self._background_type.value}"
194
+ url = f"https://maps.dwd.de/geoserver/dwd/wms?service=WMS&version=1.1.0&request=GetMap&layers={layers}&bbox={self._minx},{self._miny},{self._maxx},{self._maxy}&width={self._image_width}&height={self._image_height}&srs=EPSG:4326&styles=&format=image/png&TIME={date.strftime("%Y-%m-%dT%H:%M:00.0Z")}"
177
195
  request = requests.get(url, stream=True)
178
196
  if request.status_code != 200:
179
197
  raise ConnectionError("Error during image request from DWD servers")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: simple_dwd_weatherforecast
3
- Version: 2.1.1
3
+ Version: 2.1.2
4
4
  Summary: A simple tool to retrieve a weather forecast from DWD OpenData
5
5
  Home-page: https://github.com/FL550/simple_dwd_weatherforecast.git
6
6
  Author: Max Fermor
@@ -21,6 +21,8 @@ Requires-Dist: arrow
21
21
 
22
22
  DISCLAIMER: This project is a private open source project and doesn't have any connection with Deutscher Wetterdienst.
23
23
 
24
+ **If you like my work, I would be really happy if you buy me some coffee: [Buy Me A Coffee][buymecoffee]**
25
+
24
26
  ## Weather data
25
27
 
26
28
  This is a python package for simple access to hourly forecast data for the next 10 days. The data is updated every six hours and updated when needed. Some stations also have actual reported weather, which you can also retrieve.
@@ -208,7 +210,7 @@ get_map(minx,miny,maxx,maxy, map_type: WeatherMapType, background_type: WeatherB
208
210
 
209
211
  ### Image loop
210
212
 
211
- There is also the possibility to retrieve multiple images as a loop. This can be done by the class ImageLoop.
213
+ There is also the possibility to retrieve multiple images as a loop. This can be done by the class ImageLoop. This however only works for the precipitation radar.
212
214
 
213
215
 
214
216
  #### Usage example
@@ -1,6 +1,6 @@
1
1
  simple_dwd_weatherforecast/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- simple_dwd_weatherforecast/dwdforecast.py,sha256=ctnQAFJS8UID9KZCXK7O5FyBTlgkqvoNLVJG5E3TkCY,36694
3
- simple_dwd_weatherforecast/dwdmap.py,sha256=xIgUiyDd6gHNDGlDL2hp-Koz11s1XPtMdeVwj_1Pmnk,6020
2
+ simple_dwd_weatherforecast/dwdforecast.py,sha256=te_MIe6GzZ32UWrDTpbqJdHk0hFhs9dqN4h2rS3J-yA,38247
3
+ simple_dwd_weatherforecast/dwdmap.py,sha256=cPCcL1u5qeIEDLBcL0qOH0-7dPIi3mge4-PUqfZlRQc,6737
4
4
  simple_dwd_weatherforecast/stations.json,sha256=1u8qc2CT_rVy49SAlOicGixzHln6Y0FXevuFAz2maBw,838948
5
5
  simple_dwd_weatherforecast/uv_stations.json,sha256=ADenYo-aR6qbf0UFkfYr72kkFzL9HyUKe4VQ23POGF8,2292
6
6
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -26,17 +26,17 @@ tests/test_is_in_timerange.py,sha256=3y88L3N73NxSTJ-_edx6OCnxHWKJWWFma98gjZvJDGg
26
26
  tests/test_is_valid_timeframe.py,sha256=mXjeu3lUyixiBUEljirTf6qDM_FZFQGWa-Rk0NBMUDU,891
27
27
  tests/test_location_tools.py,sha256=wto_XzVnARJQ-Qc83YAn0ahfMBSaOHpfzqAeKRDsNm8,1208
28
28
  tests/test_map.py,sha256=uKxNjMXLFT3pczZKLqkfPK5xaVfmql-r5L9VPgCbS3Q,5671
29
- tests/test_parsekml.py,sha256=Y0EHJi5e8lqFUS8N9yfA5ByQkKV4-iyrwUaIipQpM7w,1065
29
+ tests/test_parsekml.py,sha256=mpje7FoMIz566BLW-Fr_69cxLyS7w4qvrSOIlxVYoUU,1625
30
30
  tests/test_region.py,sha256=ReUB9Cy9roBemkpEkTjZZav-Mu3Ha7ADOAfa9J-gi80,877
31
31
  tests/test_reported_weather.py,sha256=ULg4ogZRxus01p2rdxiSFL75AisqtcvnLDOc7uJMBH0,767
32
32
  tests/test_station.py,sha256=Zjx-q0yxKVxVI_L1yB_bqY5pjZPoa1L94uC8Gx6shdY,1026
33
33
  tests/test_stationsfile.py,sha256=slRH5N4Gznr6tkN2oMFWJbVCw3Xrma7Hvzn1lG5E-Qg,1401
34
- tests/test_update.py,sha256=r763R-MfqFqQQy43PcmE9yWAiQU6T2rQ1u55ujlwMt8,7216
35
- tests/test_update_hourly.py,sha256=mUc66JHndgIMdZ0DD_KXQTbPPg1g86F6CPUwJYjBV5U,1619
34
+ tests/test_update.py,sha256=AIzzHMxcjwQjeTB0l3YFgB7HkGDbuqiHofwy41mS0m4,7440
35
+ tests/test_update_hourly.py,sha256=7Zl8ml3FTdqw3_Qwr_Tz-sWTzypvrBWmxeig2Vwp_ZQ,1781
36
36
  tests/test_uv_index.py,sha256=tr6wnOyHlXT1S3yp1oeHc4-Brmc-EMEdM4mtyrdpcHg,579
37
37
  tests/test_weather.py,sha256=ZyX4ldUoJpJp7YpiNQwU6Od-nYRay-3qcaDJdNq8fhY,780
38
- simple_dwd_weatherforecast-2.1.1.dist-info/LICENCE,sha256=27UG7gteqvSWuZlsbIq2_OAbh7VyifGGl-1zpuUoBcw,1072
39
- simple_dwd_weatherforecast-2.1.1.dist-info/METADATA,sha256=fu3NJyDqNgLScF7xwu9sTBrJNYEZ0UpmpSGxipZdlqk,11893
40
- simple_dwd_weatherforecast-2.1.1.dist-info/WHEEL,sha256=uCRv0ZEik_232NlR4YDw4Pv3Ajt5bKvMH13NUU7hFuI,91
41
- simple_dwd_weatherforecast-2.1.1.dist-info/top_level.txt,sha256=iyEobUh14Tzitx39Oi8qm0NhBrnZovl_dNKtvLUkLEM,33
42
- simple_dwd_weatherforecast-2.1.1.dist-info/RECORD,,
38
+ simple_dwd_weatherforecast-2.1.2.dist-info/LICENCE,sha256=27UG7gteqvSWuZlsbIq2_OAbh7VyifGGl-1zpuUoBcw,1072
39
+ simple_dwd_weatherforecast-2.1.2.dist-info/METADATA,sha256=U8Nf45jVUBJipMq-HRG7zd9ILGyn_ezc_dv6ctF9uGs,12054
40
+ simple_dwd_weatherforecast-2.1.2.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
41
+ simple_dwd_weatherforecast-2.1.2.dist-info/top_level.txt,sha256=iyEobUh14Tzitx39Oi8qm0NhBrnZovl_dNKtvLUkLEM,33
42
+ simple_dwd_weatherforecast-2.1.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (74.1.1)
2
+ Generator: setuptools (74.1.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
tests/test_parsekml.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from datetime import datetime, timezone
2
2
  import unittest
3
+ from unittest.mock import patch
3
4
 
4
5
  from simple_dwd_weatherforecast import dwdforecast
5
6
  import dummy_data
@@ -14,9 +15,24 @@ class KMLParseTestCase(unittest.TestCase):
14
15
 
15
16
  def test_parse_kml(self):
16
17
  with open(self.FILE_NAME, "rb") as kml:
17
- self.dwd_weather.parse_kml(kml)
18
+ self.dwd_weather.parse_kml(kml.read())
18
19
  self.assertEqual(self.dwd_weather.forecast_data, dummy_data.parsed_data)
19
- self.assertEqual(self.dwd_weather.issue_time, datetime(2020, 11, 6, 3, 0, tzinfo=timezone.utc))
20
+ self.assertEqual(
21
+ self.dwd_weather.issue_time,
22
+ datetime(2020, 11, 6, 3, 0, tzinfo=timezone.utc),
23
+ )
24
+
25
+
26
+ def helper():
27
+ result = []
28
+ read_size = 131072
29
+ # Iterable that yields the bytes of a zip file
30
+ with open("development/MOSMIX_L_2023100809_stripped.kml", "rb") as kml:
31
+ content = kml.read(read_size)
32
+ while len(content) > 0:
33
+ result.append(content)
34
+ content = kml.read(read_size)
35
+ return zip([0], [0], [result])
20
36
 
21
37
 
22
38
  class KMLParseFullTestCase(unittest.TestCase):
@@ -25,9 +41,12 @@ class KMLParseFullTestCase(unittest.TestCase):
25
41
  def setUp(self):
26
42
  self.dwd_weather = dwdforecast.Weather("L511")
27
43
 
28
- def test_parse_kml(self):
29
- with open(self.FILE_NAME, "rb") as kml:
30
- self.dwd_weather.parse_kml(kml)
31
- self.assertEqual(
32
- self.dwd_weather.forecast_data, dummy_data_full.parsed_data
33
- )
44
+ @patch(
45
+ "simple_dwd_weatherforecast.dwdforecast.Weather.get_chunks",
46
+ return_value=helper(),
47
+ )
48
+ def test_parse_kml(self, _):
49
+ self.dwd_weather.download_latest_kml(
50
+ self.dwd_weather.station_id, force_hourly=True
51
+ )
52
+ self.assertEqual(self.dwd_weather.forecast_data, dummy_data_full.parsed_data)
tests/test_update.py CHANGED
@@ -22,6 +22,9 @@ class WeatherUpdate(unittest.TestCase):
22
22
  def test_download(self, _1, _2):
23
23
  self.dwd_weather.update()
24
24
  self.assertIsNotNone(self.dwd_weather.forecast_data)
25
+ self.assertIsNotNone(self.dwd_weather.forecast_data)
26
+ self.assertEqual(self.dwd_weather.station_id, "H889")
27
+ self.assertEqual(self.dwd_weather.issue_time.date(), datetime.now().date()) # type: ignore
25
28
 
26
29
  @patch(
27
30
  "simple_dwd_weatherforecast.dwdforecast.Weather.download_latest_report",
@@ -55,7 +58,7 @@ class WeatherUpdate(unittest.TestCase):
55
58
  self.dwd_weather.issue_time = datetime(
56
59
  *(time.strptime("2020-11-06T03:00:00.000Z", "%Y-%m-%dT%H:%M:%S.%fZ")[0:6]),
57
60
  0,
58
- timezone.utc
61
+ timezone.utc,
59
62
  )
60
63
  self.dwd_weather.update()
61
64
  mock_function.assert_called()
@@ -14,6 +14,8 @@ class WeatherUpdate(unittest.TestCase):
14
14
  def test_download(self):
15
15
  self.dwd_weather.update(force_hourly=True)
16
16
  self.assertIsNotNone(self.dwd_weather.forecast_data)
17
+ self.assertEqual(self.dwd_weather.station_id, "H889")
18
+ self.assertEqual(self.dwd_weather.issue_time.date(), datetime.now().date()) # type: ignore
17
19
 
18
20
  @patch(
19
21
  "simple_dwd_weatherforecast.dwdforecast.Weather.download_latest_kml",