bloomtracker 0.4.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.
@@ -0,0 +1,33 @@
1
+ """
2
+ dwdpollen - API client for the "Deutscher Wetterdienst" to get the current pollen load in Germany
3
+ Copyright (C) 2019-2025 Sascha Triller
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
18
+
19
+ import logging
20
+
21
+ # Import submodules
22
+ from .client import DwdPollenApi
23
+ from .async_client import AsyncDwdPollenApi
24
+ from .exceptions import DwdPollenError
25
+ from .constants import REGIONS, ALLERGENS
26
+
27
+ # Setup logging
28
+ logging.basicConfig(level=logging.ERROR)
29
+ LOGGER = logging.getLogger(__name__)
30
+ LOGGER.setLevel(logging.DEBUG)
31
+
32
+ __version__ = '0.4.0'
33
+ __all__ = ['DwdPollenApi', 'AsyncDwdPollenApi', 'DwdPollenError', 'REGIONS', 'ALLERGENS']
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for the dwdpollen package.
4
+ """
5
+
6
+ import sys
7
+ from bloomtracker.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main() # main doesn't return an exit code
11
+ sys.exit(0)
@@ -0,0 +1,246 @@
1
+ """
2
+ Asynchronous API client implementation for DWD pollen data.
3
+ """
4
+
5
+ import asyncio
6
+ import datetime
7
+ from typing import Dict, Any, Union, Optional, List, Tuple
8
+ import pytz
9
+ import aiohttp
10
+
11
+ from .exceptions import DwdPollenError
12
+ from .client import build_legend
13
+
14
+ DWD_URL = 'https://opendata.dwd.de/climate_environment/health/alerts/s31fg.json'
15
+
16
+
17
+ async def get_data_async(url: str, timeout: int = 30) -> Dict[str, Any]:
18
+ """
19
+ Fetch the data via HTTP asynchronously and return it as a dictionary.
20
+
21
+ Args:
22
+ url: The API URL.
23
+ timeout: Request timeout in seconds.
24
+
25
+ Returns:
26
+ The API response as a dictionary.
27
+
28
+ Raises:
29
+ DwdPollenError: If the request fails.
30
+ """
31
+ try:
32
+ timeout_obj = aiohttp.ClientTimeout(total=timeout)
33
+ async with aiohttp.ClientSession(timeout=timeout_obj) as session:
34
+ async with session.get(url) as response:
35
+ await response.raise_for_status() # type: ignore[func-returns-value] # Only for side effects
36
+ return await response.json()
37
+ except Exception as e:
38
+ # Catch all exceptions and wrap them in DwdPollenError
39
+ raise DwdPollenError(f"Failed to fetch data: {str(e)}") from e
40
+
41
+
42
+ class AsyncDwdPollenApi:
43
+ """Asynchronous API client object to get the current pollen load in Germany."""
44
+
45
+ def __init__(self, auto_update: bool = False):
46
+ """
47
+ Initialize the asynchronous DWD pollen API client.
48
+
49
+ Args:
50
+ auto_update: Whether to update data on initialization.
51
+ """
52
+ self.last_update: Optional[datetime.datetime] = None
53
+ self.next_update: Optional[datetime.datetime] = None
54
+ self.content = None
55
+ self.data: Dict[str, Dict[str, Any]] = {}
56
+ self.legend: Optional[Dict[str, str]] = None
57
+ # Will be set if auto_update is called
58
+ self._initialization_task = None
59
+ if auto_update:
60
+ self._initialization_task = asyncio.create_task(self.update())
61
+
62
+ async def build_pollen(self, allergen: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
63
+ """
64
+ Transform the pollen load of one allergen into something useful.
65
+
66
+ Args:
67
+ allergen: One allergen dictionary as it is returned by the API.
68
+
69
+ Returns:
70
+ A dictionary of dictionaries with dates as keys and allergen values as values.
71
+ """
72
+
73
+ def build_values(value: str) -> Dict[str, Union[float, str]]:
74
+ if self.legend is None:
75
+ raise DwdPollenError("Legend data not available")
76
+ return {
77
+ 'value': calculate_value(value),
78
+ 'raw': value,
79
+ 'human': self.legend[value],
80
+ 'color': get_color_for_value(calculate_value(value))
81
+ }
82
+
83
+ def calculate_value(value: str) -> float:
84
+ items = value.split('-')
85
+ result = 0
86
+ for item in items:
87
+ result += int(item)
88
+ return result / len(items)
89
+
90
+ def get_color_for_value(value: float) -> str:
91
+ """Return a color representation for the value."""
92
+ if value <= 0.0:
93
+ return '#00FF00' # Green - No load
94
+ if value <= 1.0:
95
+ return '#ADFF2F' # GreenYellow - Low load
96
+ if value <= 2.0:
97
+ return '#FFFF00' # Yellow - Medium load
98
+ if value <= 2.5:
99
+ return '#FFA500' # Orange - Medium-high load
100
+ return '#FF0000' # Red - High load
101
+
102
+ new_pollen: Dict[str, Dict[str, Any]] = {}
103
+ today = datetime.datetime.now(pytz.timezone('Europe/Berlin'))
104
+ tomorrow = today + datetime.timedelta(days=1)
105
+ day_after_tomorrow = today + datetime.timedelta(days=2)
106
+
107
+ if today.weekday() < 4: # Monday - Thursday
108
+ new_pollen = {
109
+ today.strftime('%Y-%m-%d'): build_values(allergen['today']),
110
+ tomorrow.strftime('%Y-%m-%d'): build_values(allergen['tomorrow'])
111
+ }
112
+ elif today.weekday() == 4: # Friday
113
+ new_pollen = {
114
+ today.strftime('%Y-%m-%d'): build_values(allergen['today']),
115
+ tomorrow.strftime('%Y-%m-%d'): build_values(allergen['tomorrow'])
116
+ }
117
+ if allergen['dayafter_to'] != '-1':
118
+ new_pollen[day_after_tomorrow.strftime('%Y-%m-%d')] = \
119
+ build_values(allergen['dayafter_to'])
120
+ elif today.weekday() == 5: # Saturday
121
+ new_pollen = {
122
+ today.strftime('%Y-%m-%d'): build_values(allergen['tomorrow']),
123
+ }
124
+ if allergen['dayafter_to'] != '-1':
125
+ new_pollen[day_after_tomorrow.strftime('%Y-%m-%d')] = \
126
+ build_values(allergen['dayafter_to'])
127
+ elif today.weekday() == 6: # Sunday
128
+ new_pollen = {}
129
+ if allergen['dayafter_to'] != '-1':
130
+ new_pollen[day_after_tomorrow.strftime('%Y-%m-%d')] = \
131
+ build_values(allergen['dayafter_to'])
132
+ return new_pollen
133
+
134
+ async def update(self) -> None:
135
+ """Update all pollen data."""
136
+ data = await get_data_async(DWD_URL)
137
+ self.last_update = datetime.datetime.strptime(
138
+ data['last_update'], '%Y-%m-%d %H:%M Uhr')
139
+ self.next_update = datetime.datetime.strptime(
140
+ data['next_update'], '%Y-%m-%d %H:%M Uhr')
141
+ self.legend = build_legend(data['legend'])
142
+
143
+ for region in data['content']:
144
+ new_region = {
145
+ 'region_id': region['region_id'],
146
+ 'region_name': region['region_name'],
147
+ 'partregion_id': region['partregion_id'],
148
+ 'partregion_name': region['partregion_name'],
149
+ 'last_update': self.last_update,
150
+ 'next_update': self.next_update,
151
+ 'pollen': {}
152
+ }
153
+ for allergen, pollen in region['Pollen'].items():
154
+ new_pollen = await self.build_pollen(pollen)
155
+ new_region['pollen'][allergen] = new_pollen
156
+ self.data[f"{region['region_id']}-{region['partregion_id']}"] = new_region
157
+
158
+ async def get_pollen(self, region_id: Union[int, str],
159
+ partregion_id: Union[int, str]) -> Dict[str, Any]:
160
+ """
161
+ Get the pollen load of the requested region and partregion.
162
+
163
+ Args:
164
+ region_id: API ID of the region.
165
+ partregion_id: API ID of the partregion.
166
+
167
+ Returns:
168
+ A dictionary with all pollen information of the requested (part)region.
169
+
170
+ Raises:
171
+ KeyError: If the region is not found.
172
+ """
173
+ # Wait for initialization if it's still running
174
+ if self._initialization_task is not None:
175
+ await self._initialization_task
176
+ self._initialization_task = None
177
+
178
+ key = f'{region_id}-{partregion_id}'
179
+ if key not in self.data:
180
+ # Try to update once if the key is not found
181
+ await self.update()
182
+
183
+ return self.data[key]
184
+
185
+ async def get_region_names(self) -> List[Tuple[int, int, str, str]]:
186
+ """
187
+ Get a list of available regions and their IDs.
188
+
189
+ Returns:
190
+ List of tuples with (region_id, partregion_id, region_name, partregion_name)
191
+ """
192
+ # Wait for initialization if it's still running
193
+ if self._initialization_task is not None:
194
+ await self._initialization_task
195
+ self._initialization_task = None
196
+
197
+ result = []
198
+ for _, region in self.data.items():
199
+ result.append((
200
+ region['region_id'],
201
+ region['partregion_id'],
202
+ region['region_name'],
203
+ region['partregion_name']
204
+ ))
205
+ return sorted(result)
206
+
207
+ async def get_allergen_names(self) -> List[str]:
208
+ """
209
+ Get a list of all allergen names in the data.
210
+
211
+ Returns:
212
+ List of allergen names.
213
+ """
214
+ # Wait for initialization if it's still running
215
+ if self._initialization_task is not None:
216
+ await self._initialization_task
217
+ self._initialization_task = None
218
+
219
+ allergens = set()
220
+ for _, region in self.data.items():
221
+ for allergen in region['pollen'].keys():
222
+ allergens.add(allergen)
223
+ return sorted(list(allergens))
224
+
225
+ async def get_allergen_for_region(
226
+ self,
227
+ region_id: Union[int, str],
228
+ partregion_id: Union[int, str],
229
+ allergen_name: str
230
+ ) -> Dict[str, Any]:
231
+ """
232
+ Get a specific allergen's data for a region.
233
+
234
+ Args:
235
+ region_id: API ID of the region.
236
+ partregion_id: API ID of the partregion.
237
+ allergen_name: Name of the allergen.
238
+
239
+ Returns:
240
+ Dictionary with the allergen data.
241
+
242
+ Raises:
243
+ KeyError: If the region or allergen is not found.
244
+ """
245
+ region_data = await self.get_pollen(region_id, partregion_id)
246
+ return region_data['pollen'][allergen_name]
bloomtracker/cli.py ADDED
@@ -0,0 +1,174 @@
1
+ """
2
+ CLI interface for the bloomtracker package.
3
+ JSON-only CLI tool for getting pollen data.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ import textwrap
10
+ import datetime
11
+ from typing import Dict, Any, Optional, List, TextIO
12
+
13
+ from .client import DwdPollenApi
14
+ from .constants import REGIONS
15
+
16
+
17
+ # Custom JSON encoder to handle datetime objects
18
+ class DateTimeEncoder(json.JSONEncoder):
19
+ """Custom JSON encoder to handle datetime objects."""
20
+ def default(self, o):
21
+ if isinstance(o, (datetime.datetime, datetime.date)):
22
+ return o.isoformat()
23
+ return super().default(o)
24
+
25
+
26
+ def convert_datetime_recursive(obj):
27
+ """Recursively convert datetime objects to ISO format strings."""
28
+ if isinstance(obj, datetime.datetime):
29
+ return obj.isoformat()
30
+ if isinstance(obj, dict):
31
+ return {key: convert_datetime_recursive(value) for key, value in obj.items()}
32
+ if isinstance(obj, list):
33
+ return [convert_datetime_recursive(item) for item in obj]
34
+ return obj
35
+
36
+ def get_pollen_data(api: DwdPollenApi, region_id: int, partregion_id: int) -> Dict[str, Any]:
37
+ """Get pollen data for a region and return it in a serializable format."""
38
+ try:
39
+ data = api.get_pollen(region_id, partregion_id)
40
+
41
+ # Recursively convert all datetime objects to strings for JSON serialization
42
+ serializable_data = convert_datetime_recursive(data)
43
+
44
+ return serializable_data
45
+ except KeyError:
46
+ return {
47
+ "error": f"Region {region_id}-{partregion_id} not found.",
48
+ "status": "error",
49
+ "code": 404
50
+ }
51
+
52
+
53
+ def print_json(
54
+ api: DwdPollenApi,
55
+ region_id: int,
56
+ partregion_id: int,
57
+ output_file: Optional[TextIO] = None
58
+ ) -> None:
59
+ """Print pollen data as JSON."""
60
+ data = get_pollen_data(api, region_id, partregion_id)
61
+
62
+ # Use global DateTimeEncoder class for consistent datetime serialization
63
+ output = json.dumps(data, ensure_ascii=False, indent=2, cls=DateTimeEncoder)
64
+
65
+ if output_file:
66
+ output_file.write(output)
67
+ else:
68
+ print(output)
69
+
70
+
71
+ def get_regions_data() -> Dict[str, Any]:
72
+ """Get information about all available regions in JSON format."""
73
+ regions_data: Dict[str, List[Dict[str, Any]]] = {
74
+ "regions": []
75
+ }
76
+
77
+ for region_id, (region_name, partregions) in REGIONS.items():
78
+ # pylint: disable-next=no-member
79
+ for partregion_id, partregion_name in partregions.items():
80
+ full_name = region_name
81
+ if partregion_name:
82
+ full_name += f" - {partregion_name}"
83
+
84
+ regions_data["regions"].append({
85
+ "region_id": region_id,
86
+ "partregion_id": partregion_id,
87
+ "name": full_name
88
+ })
89
+
90
+ return regions_data
91
+
92
+
93
+ def main() -> None:
94
+ """Main entry point for the CLI."""
95
+ parser = argparse.ArgumentParser(
96
+ description="Get pollen load data from the Deutscher Wetterdienst (JSON output only)",
97
+ formatter_class=argparse.RawDescriptionHelpFormatter,
98
+ epilog=textwrap.dedent("""
99
+ Examples:
100
+ bloomtracker -r 10 -p 11 # Get data for Schleswig-Holstein (Inseln und Marschen)
101
+ bloomtracker --list # List all available regions
102
+ bloomtracker -r 50 -o data.json # Save data for Berlin/Brandenburg to JSON file
103
+ """)
104
+ )
105
+
106
+ parser.add_argument(
107
+ "-r", "--region",
108
+ type=int,
109
+ help="Region ID"
110
+ )
111
+
112
+ parser.add_argument(
113
+ "-p", "--partregion",
114
+ type=int,
115
+ default=-1,
116
+ help="Partregion ID (default: -1)"
117
+ )
118
+
119
+ parser.add_argument(
120
+ "-o", "--output",
121
+ type=argparse.FileType('w'),
122
+ help="Output file (default: stdout)"
123
+ )
124
+
125
+ parser.add_argument(
126
+ "--no-cache",
127
+ action="store_true",
128
+ help="Bypass cache and force data update"
129
+ )
130
+
131
+ parser.add_argument(
132
+ "-l", "--list",
133
+ action="store_true",
134
+ help="List all available regions"
135
+ )
136
+
137
+ args = parser.parse_args()
138
+
139
+ api = DwdPollenApi()
140
+
141
+ if args.no_cache:
142
+ api.update(force=True)
143
+
144
+ if args.list:
145
+ # Output the list of regions as JSON
146
+ regions_data = get_regions_data()
147
+ print(json.dumps(regions_data, ensure_ascii=False, indent=2, cls=DateTimeEncoder))
148
+ return
149
+
150
+ if args.region is None:
151
+ # Output help as JSON
152
+ help_data = {
153
+ "status": "error",
154
+ "error": "Missing required argument: region",
155
+ "help": "Run with --help for usage information"
156
+ }
157
+ print(json.dumps(help_data, ensure_ascii=False, indent=2, cls=DateTimeEncoder))
158
+ sys.exit(1)
159
+
160
+ try:
161
+ # Always print JSON
162
+ print_json(api, args.region, args.partregion, args.output)
163
+ except (KeyError, ValueError) as e:
164
+ error_data = {
165
+ "status": "error",
166
+ "error": str(e),
167
+ "code": 500
168
+ }
169
+ print(json.dumps(error_data, ensure_ascii=False, indent=2, cls=DateTimeEncoder))
170
+ sys.exit(1)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()