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.
- bloomtracker/__init__.py +33 -0
- bloomtracker/__main__.py +11 -0
- bloomtracker/async_client.py +246 -0
- bloomtracker/cli.py +174 -0
- bloomtracker/client.py +389 -0
- bloomtracker/constants.py +96 -0
- bloomtracker/exceptions.py +15 -0
- bloomtracker/utils.py +0 -0
- bloomtracker/visualization.py +43 -0
- bloomtracker-0.4.0.data/scripts/bloomtracker +10 -0
- bloomtracker-0.4.0.dist-info/METADATA +329 -0
- bloomtracker-0.4.0.dist-info/RECORD +16 -0
- bloomtracker-0.4.0.dist-info/WHEEL +5 -0
- bloomtracker-0.4.0.dist-info/entry_points.txt +2 -0
- bloomtracker-0.4.0.dist-info/licenses/LICENSE +674 -0
- bloomtracker-0.4.0.dist-info/top_level.txt +1 -0
bloomtracker/__init__.py
ADDED
|
@@ -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']
|
bloomtracker/__main__.py
ADDED
|
@@ -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()
|