meteo-lt-pkg 0.4.0b1__tar.gz → 0.5.0b0__tar.gz
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.
- {meteo_lt_pkg-0.4.0b1/meteo_lt_pkg.egg-info → meteo_lt_pkg-0.5.0b0}/PKG-INFO +11 -11
- meteo_lt_pkg-0.5.0b0/meteo_lt/__init__.py +27 -0
- meteo_lt_pkg-0.5.0b0/meteo_lt/api.py +109 -0
- meteo_lt_pkg-0.5.0b0/meteo_lt/client.py +163 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/const.py +1 -4
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/models.py +61 -37
- meteo_lt_pkg-0.5.0b0/meteo_lt/utils.py +37 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/warnings.py +39 -79
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0/meteo_lt_pkg.egg-info}/PKG-INFO +11 -11
- meteo_lt_pkg-0.5.0b0/meteo_lt_pkg.egg-info/requires.txt +12 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/pyproject.toml +14 -11
- meteo_lt_pkg-0.4.0b1/meteo_lt/__init__.py +0 -13
- meteo_lt_pkg-0.4.0b1/meteo_lt/api.py +0 -94
- meteo_lt_pkg-0.4.0b1/meteo_lt/client.py +0 -80
- meteo_lt_pkg-0.4.0b1/meteo_lt/utils.py +0 -34
- meteo_lt_pkg-0.4.0b1/meteo_lt_pkg.egg-info/requires.txt +0 -12
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/LICENSE +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/MANIFEST.in +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/README.md +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/SOURCES.txt +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/dependency_links.txt +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/top_level.txt +0 -0
- {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meteo_lt-pkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0b0
|
|
4
4
|
Summary: A library to fetch weather data from api.meteo.lt
|
|
5
5
|
Author-email: Brunas <brunonas@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/Brunas/meteo_lt-pkg
|
|
@@ -12,17 +12,17 @@ Classifier: Development Status :: 4 - Beta
|
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
|
-
Requires-Dist: aiohttp
|
|
15
|
+
Requires-Dist: aiohttp>=3.13
|
|
16
16
|
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: pytest; extra == "dev"
|
|
18
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
|
-
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
20
|
-
Requires-Dist: black; extra == "dev"
|
|
21
|
-
Requires-Dist: coverage; extra == "dev"
|
|
22
|
-
Requires-Dist: flake8; extra == "dev"
|
|
23
|
-
Requires-Dist: pyflakes; extra == "dev"
|
|
24
|
-
Requires-Dist: pylint; extra == "dev"
|
|
25
|
-
Requires-Dist: build; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
|
|
20
|
+
Requires-Dist: black>=26.0; extra == "dev"
|
|
21
|
+
Requires-Dist: coverage>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pyflakes>=3.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pylint>=4.0; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
26
26
|
Dynamic: license-file
|
|
27
27
|
|
|
28
28
|
# Meteo.Lt Lithuanian weather forecast package
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""init.py"""
|
|
2
|
+
|
|
3
|
+
from .api import MeteoLtAPI
|
|
4
|
+
from .models import (
|
|
5
|
+
Coordinates,
|
|
6
|
+
LocationBase,
|
|
7
|
+
Place,
|
|
8
|
+
ForecastTimestamp,
|
|
9
|
+
Forecast,
|
|
10
|
+
WeatherWarning,
|
|
11
|
+
HydroStation,
|
|
12
|
+
HydroObservation,
|
|
13
|
+
HydroObservationData,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MeteoLtAPI",
|
|
18
|
+
"Coordinates",
|
|
19
|
+
"LocationBase",
|
|
20
|
+
"Place",
|
|
21
|
+
"ForecastTimestamp",
|
|
22
|
+
"Forecast",
|
|
23
|
+
"WeatherWarning",
|
|
24
|
+
"HydroStation",
|
|
25
|
+
"HydroObservation",
|
|
26
|
+
"HydroObservationData",
|
|
27
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Main API class script"""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from .models import (
|
|
6
|
+
Forecast,
|
|
7
|
+
Place,
|
|
8
|
+
WeatherWarning,
|
|
9
|
+
HydroStation,
|
|
10
|
+
HydroObservationData,
|
|
11
|
+
)
|
|
12
|
+
from .utils import find_nearest_location
|
|
13
|
+
from .client import MeteoLtClient
|
|
14
|
+
from .warnings import WeatherWarningsProcessor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MeteoLtAPI:
|
|
18
|
+
"""Main API class that orchestrates external API calls and warning processing"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session=None):
|
|
21
|
+
self.places = []
|
|
22
|
+
self.client = MeteoLtClient(session)
|
|
23
|
+
self.warnings_processor = WeatherWarningsProcessor(self.client)
|
|
24
|
+
|
|
25
|
+
async def __aenter__(self):
|
|
26
|
+
"""Async context manager entry"""
|
|
27
|
+
await self.client.__aenter__()
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
async def __aexit__(
|
|
31
|
+
self,
|
|
32
|
+
exc_type: Optional[type],
|
|
33
|
+
exc_val: Optional[Exception],
|
|
34
|
+
exc_tb: Optional[object],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Async context manager exit"""
|
|
37
|
+
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
|
38
|
+
|
|
39
|
+
async def close(self):
|
|
40
|
+
"""Close the API client and cleanup resources"""
|
|
41
|
+
await self.client.close()
|
|
42
|
+
|
|
43
|
+
async def fetch_places(self) -> None:
|
|
44
|
+
"""Gets all places from API"""
|
|
45
|
+
self.places = await self.client.fetch_places()
|
|
46
|
+
|
|
47
|
+
async def get_nearest_place(self, latitude: float, longitude: float) -> Optional[Place]:
|
|
48
|
+
"""Finds nearest place using provided coordinates"""
|
|
49
|
+
if not self.places:
|
|
50
|
+
await self.fetch_places()
|
|
51
|
+
return find_nearest_location(latitude, longitude, self.places)
|
|
52
|
+
|
|
53
|
+
async def get_forecast_with_warnings(
|
|
54
|
+
self,
|
|
55
|
+
latitude: Optional[float] = None,
|
|
56
|
+
longitude: Optional[float] = None,
|
|
57
|
+
place_code: Optional[str] = None,
|
|
58
|
+
) -> Forecast:
|
|
59
|
+
"""Get forecast with weather warnings for a location"""
|
|
60
|
+
if place_code is None:
|
|
61
|
+
if latitude is None or longitude is None:
|
|
62
|
+
raise ValueError("Either place_code or both latitude and longitude must be provided")
|
|
63
|
+
place = await self.get_nearest_place(latitude, longitude)
|
|
64
|
+
place_code = place.code
|
|
65
|
+
|
|
66
|
+
return await self.get_forecast(place_code, include_warnings=True)
|
|
67
|
+
|
|
68
|
+
async def get_forecast(self, place_code: str, include_warnings: bool = True) -> Forecast:
|
|
69
|
+
"""Retrieves forecast data from API"""
|
|
70
|
+
forecast = await self.client.fetch_forecast(place_code)
|
|
71
|
+
|
|
72
|
+
if include_warnings:
|
|
73
|
+
await self._enrich_forecast_with_warnings(forecast)
|
|
74
|
+
|
|
75
|
+
return forecast
|
|
76
|
+
|
|
77
|
+
async def get_weather_warnings(self, administrative_division: str = None) -> List[WeatherWarning]:
|
|
78
|
+
"""Fetches weather warnings from meteo.lt JSON API"""
|
|
79
|
+
return await self.warnings_processor.get_weather_warnings(administrative_division)
|
|
80
|
+
|
|
81
|
+
async def _enrich_forecast_with_warnings(self, forecast: Forecast) -> None:
|
|
82
|
+
"""Enrich forecast timestamps with relevant weather warnings"""
|
|
83
|
+
if not forecast or not forecast.place or not forecast.place.administrative_division:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
warnings = await self.get_weather_warnings(forecast.place.administrative_division)
|
|
87
|
+
|
|
88
|
+
if warnings:
|
|
89
|
+
self.warnings_processor.enrich_forecast_with_warnings(forecast, warnings)
|
|
90
|
+
|
|
91
|
+
async def get_hydro_stations(self) -> List[HydroStation]:
|
|
92
|
+
"""Get list of all hydrological stations"""
|
|
93
|
+
return await self.client.fetch_hydro_stations()
|
|
94
|
+
|
|
95
|
+
async def get_nearest_hydro_station(self, latitude: float, longitude: float) -> Optional[HydroStation]:
|
|
96
|
+
"""Find the nearest hydrological station to given coordinates"""
|
|
97
|
+
stations = await self.get_hydro_stations()
|
|
98
|
+
if not stations:
|
|
99
|
+
return None
|
|
100
|
+
return find_nearest_location(latitude, longitude, stations)
|
|
101
|
+
|
|
102
|
+
async def get_hydro_observation_data(
|
|
103
|
+
self,
|
|
104
|
+
station_code: str,
|
|
105
|
+
observation_type: str = "measured",
|
|
106
|
+
date: str = "latest",
|
|
107
|
+
) -> HydroObservationData:
|
|
108
|
+
"""Get hydrological observation data for a station"""
|
|
109
|
+
return await self.client.fetch_hydro_observation_data(station_code, observation_type, date)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""MeteoLt API client for external API calls"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import List, Optional, Dict, Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
Place,
|
|
10
|
+
Forecast,
|
|
11
|
+
HydroStation,
|
|
12
|
+
HydroObservationData,
|
|
13
|
+
HydroObservation,
|
|
14
|
+
)
|
|
15
|
+
from .const import BASE_URL, WARNINGS_URL, TIMEOUT, ENCODING
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MeteoLtClient:
|
|
19
|
+
"""Client for external API calls to meteo.lt"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
|
|
22
|
+
self._session = session
|
|
23
|
+
self._owns_session = session is None
|
|
24
|
+
|
|
25
|
+
async def __aenter__(self):
|
|
26
|
+
"""Async context manager entry"""
|
|
27
|
+
if self._session is None:
|
|
28
|
+
self._session = aiohttp.ClientSession(
|
|
29
|
+
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
|
|
30
|
+
raise_for_status=True,
|
|
31
|
+
)
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
async def __aexit__(
|
|
35
|
+
self,
|
|
36
|
+
exc_type: Optional[type],
|
|
37
|
+
exc_val: Optional[Exception],
|
|
38
|
+
exc_tb: Optional[Any],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Async context manager exit"""
|
|
41
|
+
await self.close()
|
|
42
|
+
|
|
43
|
+
async def close(self) -> None:
|
|
44
|
+
"""Close the client session if we own it"""
|
|
45
|
+
if self._session and self._owns_session:
|
|
46
|
+
await self._session.close()
|
|
47
|
+
self._session = None
|
|
48
|
+
|
|
49
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
50
|
+
"""Get or create a session"""
|
|
51
|
+
if self._session is None:
|
|
52
|
+
self._session = aiohttp.ClientSession(
|
|
53
|
+
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
|
|
54
|
+
raise_for_status=True,
|
|
55
|
+
)
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
async def fetch_places(self) -> List[Place]:
|
|
59
|
+
"""Gets all places from API"""
|
|
60
|
+
session = await self._get_session()
|
|
61
|
+
async with session.get(f"{BASE_URL}/places") as response:
|
|
62
|
+
response.encoding = ENCODING
|
|
63
|
+
response_json = await response.json()
|
|
64
|
+
return [Place.from_dict(place) for place in response_json]
|
|
65
|
+
|
|
66
|
+
async def fetch_forecast(self, place_code: str) -> Forecast:
|
|
67
|
+
"""Retrieves forecast data from API"""
|
|
68
|
+
session = await self._get_session()
|
|
69
|
+
async with session.get(f"{BASE_URL}/places/{place_code}/forecasts/long-term") as response:
|
|
70
|
+
response.encoding = ENCODING
|
|
71
|
+
response_json = await response.json()
|
|
72
|
+
return Forecast.from_dict(response_json)
|
|
73
|
+
|
|
74
|
+
async def fetch_weather_warnings(self) -> Dict[str, Any]:
|
|
75
|
+
"""Fetches raw weather warnings data from meteo.lt JSON API"""
|
|
76
|
+
session = await self._get_session()
|
|
77
|
+
|
|
78
|
+
# Get the latest warnings file
|
|
79
|
+
async with session.get(WARNINGS_URL) as response:
|
|
80
|
+
file_list = await response.json()
|
|
81
|
+
|
|
82
|
+
if not file_list:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
# Fetch the latest warnings data
|
|
86
|
+
latest_file_url = file_list[0] # First file is the most recent
|
|
87
|
+
async with session.get(latest_file_url) as response:
|
|
88
|
+
text_data = await response.text()
|
|
89
|
+
return json.loads(text_data)
|
|
90
|
+
|
|
91
|
+
async def fetch_hydro_stations(self) -> List[HydroStation]:
|
|
92
|
+
"""Get list of all hydrological stations."""
|
|
93
|
+
session = await self._get_session()
|
|
94
|
+
async with session.get(f"{BASE_URL}/hydro-stations") as resp:
|
|
95
|
+
if resp.status == 200:
|
|
96
|
+
response = await resp.json()
|
|
97
|
+
stations = []
|
|
98
|
+
for station_data in response:
|
|
99
|
+
stations.append(
|
|
100
|
+
HydroStation(
|
|
101
|
+
code=station_data.get("code"),
|
|
102
|
+
name=station_data.get("name"),
|
|
103
|
+
water_body=station_data.get("waterBody"),
|
|
104
|
+
coordinates=station_data.get("coordinates", {}),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return stations
|
|
108
|
+
else:
|
|
109
|
+
raise Exception(f"API returned status {resp.status}")
|
|
110
|
+
|
|
111
|
+
async def fetch_hydro_station(self, station_code: str) -> HydroStation:
|
|
112
|
+
"""Get information about a specific hydrological station."""
|
|
113
|
+
session = await self._get_session()
|
|
114
|
+
async with session.get(f"{BASE_URL}/hydro-stations/{station_code}") as resp:
|
|
115
|
+
if resp.status == 200:
|
|
116
|
+
response = await resp.json()
|
|
117
|
+
return HydroStation(
|
|
118
|
+
code=response.get("code"),
|
|
119
|
+
name=response.get("name"),
|
|
120
|
+
water_body=response.get("waterBody"),
|
|
121
|
+
coordinates=response.get("coordinates", {}),
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
raise Exception(f"API returned status {resp.status}")
|
|
125
|
+
|
|
126
|
+
async def fetch_hydro_observation_data(
|
|
127
|
+
self,
|
|
128
|
+
station_code: str,
|
|
129
|
+
observation_type: str = "measured",
|
|
130
|
+
date: str = "latest",
|
|
131
|
+
) -> HydroObservationData:
|
|
132
|
+
"""Get hydrological observation data for a station."""
|
|
133
|
+
session = await self._get_session()
|
|
134
|
+
async with session.get(
|
|
135
|
+
f"{BASE_URL}/hydro-stations/{station_code}/observations/{observation_type}/{date}"
|
|
136
|
+
) as resp:
|
|
137
|
+
if resp.status == 200:
|
|
138
|
+
response = await resp.json()
|
|
139
|
+
station = HydroStation(
|
|
140
|
+
code=response["station"].get("code"),
|
|
141
|
+
name=response["station"].get("name"),
|
|
142
|
+
water_body=response["station"].get("waterBody"),
|
|
143
|
+
coordinates=response["station"].get("coordinates", {}),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
observations = []
|
|
147
|
+
for obs_data in response.get("observations", []):
|
|
148
|
+
observations.append(
|
|
149
|
+
HydroObservation(
|
|
150
|
+
observation_datetime=obs_data.get("observationTimeUtc"),
|
|
151
|
+
water_level=obs_data.get("waterLevel"),
|
|
152
|
+
water_temperature=obs_data.get("waterTemperature"),
|
|
153
|
+
water_discharge=obs_data.get("waterDischarge"),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return HydroObservationData(
|
|
158
|
+
station=station,
|
|
159
|
+
observations_data_range=response.get("observationsDataRange"),
|
|
160
|
+
observations=observations,
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
raise Exception(f"API returned status {resp.status}")
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
"""const.py"""
|
|
2
2
|
|
|
3
3
|
BASE_URL = "https://api.meteo.lt/v1"
|
|
4
|
-
WARNINGS_URL =
|
|
5
|
-
"https://www.meteo.lt/app/mu-plugins/Meteo/Components/"
|
|
6
|
-
"WeatherWarningsNew/list_JSON.php"
|
|
7
|
-
)
|
|
4
|
+
WARNINGS_URL = "https://www.meteo.lt/app/mu-plugins/Meteo/Components/" "WeatherWarningsNew/list_JSON.php"
|
|
8
5
|
TIMEOUT = 30
|
|
9
6
|
ENCODING = "utf-8"
|
|
10
7
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass, field, fields
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
|
-
from typing import List, Optional
|
|
5
|
+
from typing import List, Optional, Dict, Any, Type
|
|
6
6
|
|
|
7
7
|
from .const import COUNTY_MUNICIPALITIES
|
|
8
8
|
|
|
@@ -16,17 +16,12 @@ class Coordinates:
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|
|
19
|
-
class
|
|
20
|
-
"""
|
|
19
|
+
class LocationBase:
|
|
20
|
+
"""Base class for locations with coordinates"""
|
|
21
21
|
|
|
22
22
|
code: str
|
|
23
23
|
name: str
|
|
24
|
-
administrative_division: str = field(
|
|
25
|
-
metadata={"json_key": "administrativeDivision"}
|
|
26
|
-
)
|
|
27
|
-
country_code: str = field(metadata={"json_key": "countryCode"})
|
|
28
24
|
coordinates: Coordinates
|
|
29
|
-
counties: List[str] = field(init=False)
|
|
30
25
|
|
|
31
26
|
@property
|
|
32
27
|
def latitude(self):
|
|
@@ -38,16 +33,60 @@ class Place:
|
|
|
38
33
|
"""Longitude from coordinates"""
|
|
39
34
|
return self.coordinates.longitude
|
|
40
35
|
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Place(LocationBase):
|
|
39
|
+
"""Places"""
|
|
40
|
+
|
|
41
|
+
administrative_division: str = field(metadata={"json_key": "administrativeDivision"})
|
|
42
|
+
country_code: str = field(metadata={"json_key": "countryCode"})
|
|
43
|
+
counties: List[str] = field(init=False)
|
|
44
|
+
|
|
41
45
|
def __post_init__(self):
|
|
42
46
|
self.counties = []
|
|
43
47
|
for county, municipalities in COUNTY_MUNICIPALITIES.items():
|
|
44
|
-
if (
|
|
45
|
-
self.administrative_division.replace(" savivaldybė", "")
|
|
46
|
-
in municipalities
|
|
47
|
-
):
|
|
48
|
+
if self.administrative_division.replace(" savivaldybė", "") in municipalities:
|
|
48
49
|
self.counties.append(county)
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
@dataclass
|
|
53
|
+
class WeatherWarning:
|
|
54
|
+
"""Weather Warning"""
|
|
55
|
+
|
|
56
|
+
county: str
|
|
57
|
+
warning_type: str
|
|
58
|
+
severity: str
|
|
59
|
+
description: str
|
|
60
|
+
start_time: Optional[str] = None
|
|
61
|
+
end_time: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class HydroStation(LocationBase):
|
|
66
|
+
"""Hydrological station data."""
|
|
67
|
+
|
|
68
|
+
water_body: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class HydroObservation:
|
|
73
|
+
"""Single hydrological observation."""
|
|
74
|
+
|
|
75
|
+
observation_datetime: Optional[str] = None
|
|
76
|
+
water_level: Optional[float] = None # cm
|
|
77
|
+
water_temperature: Optional[float] = None # °C
|
|
78
|
+
water_discharge: Optional[float] = None # m3/s
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class HydroObservationData:
|
|
83
|
+
"""Observation data response."""
|
|
84
|
+
|
|
85
|
+
station: HydroStation
|
|
86
|
+
observations_data_range: Optional[dict] = None
|
|
87
|
+
observations: List[HydroObservation] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
|
|
51
90
|
@dataclass
|
|
52
91
|
class ForecastTimestamp:
|
|
53
92
|
"""ForecastTimestamp"""
|
|
@@ -63,7 +102,7 @@ class ForecastTimestamp:
|
|
|
63
102
|
pressure: float = field(metadata={"json_key": "seaLevelPressure"})
|
|
64
103
|
humidity: float = field(metadata={"json_key": "relativeHumidity"})
|
|
65
104
|
precipitation: float = field(metadata={"json_key": "totalPrecipitation"})
|
|
66
|
-
warnings: List[
|
|
105
|
+
warnings: List[WeatherWarning] = field(default_factory=list, init=False)
|
|
67
106
|
|
|
68
107
|
|
|
69
108
|
@dataclass
|
|
@@ -73,16 +112,12 @@ class Forecast:
|
|
|
73
112
|
place: Place
|
|
74
113
|
forecast_created: str = field(metadata={"json_key": "forecastCreationTimeUtc"})
|
|
75
114
|
current_conditions: ForecastTimestamp
|
|
76
|
-
forecast_timestamps: List[ForecastTimestamp] = field(
|
|
77
|
-
metadata={"json_key": "forecastTimestamps"}
|
|
78
|
-
)
|
|
115
|
+
forecast_timestamps: List[ForecastTimestamp] = field(metadata={"json_key": "forecastTimestamps"})
|
|
79
116
|
|
|
80
117
|
def __post_init__(self):
|
|
81
118
|
"""Post-initialization processing."""
|
|
82
119
|
|
|
83
|
-
current_hour = datetime.now(timezone.utc).replace(
|
|
84
|
-
minute=0, second=0, microsecond=0
|
|
85
|
-
)
|
|
120
|
+
current_hour = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
|
86
121
|
# Current conditions are equal to current hour record
|
|
87
122
|
for forecast in self.forecast_timestamps:
|
|
88
123
|
if (
|
|
@@ -106,9 +141,9 @@ class Forecast:
|
|
|
106
141
|
]
|
|
107
142
|
|
|
108
143
|
|
|
109
|
-
def from_dict(cls, data:
|
|
144
|
+
def from_dict(cls: Type, data: Dict[str, Any]) -> Any:
|
|
110
145
|
"""Utility function to convert a dictionary to a dataclass instance."""
|
|
111
|
-
init_args = {}
|
|
146
|
+
init_args: Dict[str, Any] = {}
|
|
112
147
|
for f in fields(cls):
|
|
113
148
|
if not f.init:
|
|
114
149
|
continue # Skip fields that are not part of the constructor
|
|
@@ -121,31 +156,20 @@ def from_dict(cls, data: dict):
|
|
|
121
156
|
value = from_dict(f.type, value)
|
|
122
157
|
elif isinstance(value, list) and hasattr(f.type.__args__[0], "from_dict"):
|
|
123
158
|
value = [from_dict(f.type.__args__[0], item) for item in value]
|
|
124
|
-
elif f.name in ("datetime", "forecast_created"):
|
|
159
|
+
elif f.name in ("datetime", "forecast_created", "observation_datetime") and value:
|
|
125
160
|
# Convert datetime to ISO 8601 format
|
|
126
|
-
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(
|
|
127
|
-
tzinfo=timezone.utc
|
|
128
|
-
)
|
|
161
|
+
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
|
129
162
|
value = dt.isoformat()
|
|
130
163
|
|
|
131
164
|
init_args[f.name] = value
|
|
132
165
|
return cls(**init_args)
|
|
133
166
|
|
|
134
167
|
|
|
135
|
-
@dataclass
|
|
136
|
-
class WeatherWarning:
|
|
137
|
-
"""Weather Warning"""
|
|
138
|
-
|
|
139
|
-
county: str
|
|
140
|
-
warning_type: str
|
|
141
|
-
severity: str
|
|
142
|
-
description: str
|
|
143
|
-
start_time: Optional[str] = None
|
|
144
|
-
end_time: Optional[str] = None
|
|
145
|
-
|
|
146
|
-
|
|
147
168
|
Coordinates.from_dict = classmethod(from_dict)
|
|
148
169
|
Place.from_dict = classmethod(from_dict)
|
|
149
170
|
ForecastTimestamp.from_dict = classmethod(from_dict)
|
|
150
171
|
Forecast.from_dict = classmethod(from_dict)
|
|
151
172
|
WeatherWarning.from_dict = classmethod(from_dict)
|
|
173
|
+
HydroStation.from_dict = classmethod(from_dict)
|
|
174
|
+
HydroObservation.from_dict = classmethod(from_dict)
|
|
175
|
+
HydroObservationData.from_dict = classmethod(from_dict)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""utils.py"""
|
|
2
|
+
|
|
3
|
+
from math import radians, sin, cos, sqrt, atan2
|
|
4
|
+
from typing import TypeVar, List
|
|
5
|
+
|
|
6
|
+
LocationT = TypeVar("LocationT") # Type variable for location objects with latitude/longitude
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
10
|
+
"""Calculate the great-circle distance between two points on the Earth's surface."""
|
|
11
|
+
# Convert latitude and longitude from degrees to radians
|
|
12
|
+
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
13
|
+
|
|
14
|
+
# Haversine formula
|
|
15
|
+
dlat = lat2 - lat1
|
|
16
|
+
dlon = lon2 - lon1
|
|
17
|
+
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
18
|
+
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
19
|
+
r = 6371 # Radius of Earth in kilometers
|
|
20
|
+
return r * c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_nearest_location(latitude: float, longitude: float, locations: List[LocationT]) -> LocationT:
|
|
24
|
+
"""Find the nearest location from a list of locations based on the given latitude and longitude."""
|
|
25
|
+
nearest_location = None
|
|
26
|
+
min_distance = float("inf")
|
|
27
|
+
|
|
28
|
+
for location in locations:
|
|
29
|
+
location_lat = location.latitude
|
|
30
|
+
location_lon = location.longitude
|
|
31
|
+
distance = haversine(latitude, longitude, location_lat, location_lon)
|
|
32
|
+
|
|
33
|
+
if distance < min_distance:
|
|
34
|
+
min_distance = distance
|
|
35
|
+
nearest_location = location
|
|
36
|
+
|
|
37
|
+
return nearest_location
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
"""Weather warnings processor for handling warning-related logic"""
|
|
2
2
|
|
|
3
|
-
# pylint: disable=W0718
|
|
4
|
-
|
|
5
3
|
import re
|
|
6
4
|
from datetime import datetime, timezone
|
|
7
|
-
from typing import List
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
8
6
|
|
|
9
7
|
from .models import Forecast, WeatherWarning
|
|
10
8
|
from .const import COUNTY_MUNICIPALITIES
|
|
@@ -17,24 +15,18 @@ class WeatherWarningsProcessor:
|
|
|
17
15
|
def __init__(self, client: MeteoLtClient):
|
|
18
16
|
self.client = client
|
|
19
17
|
|
|
20
|
-
async def get_weather_warnings(
|
|
21
|
-
self, administrative_division: str = None
|
|
22
|
-
) -> List[WeatherWarning]:
|
|
18
|
+
async def get_weather_warnings(self, administrative_division: str = None) -> List[WeatherWarning]:
|
|
23
19
|
"""Fetches and processes weather warnings"""
|
|
24
20
|
warnings_data = await self.client.fetch_weather_warnings()
|
|
25
21
|
warnings = self._parse_warnings_data(warnings_data)
|
|
26
22
|
|
|
27
23
|
# Filter by administrative division if specified
|
|
28
24
|
if administrative_division:
|
|
29
|
-
warnings = [
|
|
30
|
-
w
|
|
31
|
-
for w in warnings
|
|
32
|
-
if self._warning_affects_area(w, administrative_division)
|
|
33
|
-
]
|
|
25
|
+
warnings = [w for w in warnings if self._warning_affects_area(w, administrative_division)]
|
|
34
26
|
|
|
35
27
|
return warnings
|
|
36
28
|
|
|
37
|
-
def _parse_warnings_data(self, warnings_data:
|
|
29
|
+
def _parse_warnings_data(self, warnings_data: Optional[Dict[str, Any]]) -> List[WeatherWarning]:
|
|
38
30
|
"""Parse raw warnings data into WeatherWarning objects"""
|
|
39
31
|
warnings = []
|
|
40
32
|
|
|
@@ -51,9 +43,7 @@ class WeatherWarningsProcessor:
|
|
|
51
43
|
for area_group in phenomenon_group.get("area_groups", []):
|
|
52
44
|
for alert in area_group.get("single_alerts", []):
|
|
53
45
|
# Skip alerts with no phenomenon or empty descriptions
|
|
54
|
-
if not alert.get("phenomenon") or not alert.get(
|
|
55
|
-
"description", {}
|
|
56
|
-
).get("lt"):
|
|
46
|
+
if not alert.get("phenomenon") or not alert.get("description", {}).get("lt"):
|
|
57
47
|
continue
|
|
58
48
|
|
|
59
49
|
# Create warnings for each area in the group
|
|
@@ -64,51 +54,37 @@ class WeatherWarningsProcessor:
|
|
|
64
54
|
|
|
65
55
|
return warnings
|
|
66
56
|
|
|
67
|
-
def _create_warning_from_alert(self, alert:
|
|
57
|
+
def _create_warning_from_alert(self, alert: Dict[str, Any], area: Dict[str, Any]) -> WeatherWarning:
|
|
68
58
|
"""Create a WeatherWarning from alert data"""
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
severity = alert.get("severity", "Minor")
|
|
73
|
-
|
|
74
|
-
# Clean phenomenon name (remove severity prefixes)
|
|
75
|
-
warning_type = re.sub(r"^(dangerous|severe|extreme)-", "", phenomenon)
|
|
76
|
-
|
|
77
|
-
# Get descriptions and instructions
|
|
78
|
-
desc_dict = alert.get("description", {})
|
|
79
|
-
inst_dict = alert.get("instruction", {})
|
|
80
|
-
|
|
81
|
-
# Prefer English, fall back to Lithuanian
|
|
82
|
-
description = desc_dict.get("en") or desc_dict.get("lt", "")
|
|
83
|
-
instruction = inst_dict.get("en") or inst_dict.get("lt", "")
|
|
84
|
-
|
|
85
|
-
# Combine description and instruction
|
|
86
|
-
full_description = description
|
|
87
|
-
if instruction:
|
|
88
|
-
full_description += f"\n\nRecommendations: {instruction}"
|
|
89
|
-
|
|
90
|
-
return WeatherWarning(
|
|
91
|
-
county=county,
|
|
92
|
-
warning_type=warning_type,
|
|
93
|
-
severity=severity,
|
|
94
|
-
description=full_description,
|
|
95
|
-
start_time=alert.get("t_from"),
|
|
96
|
-
end_time=alert.get("t_to"),
|
|
97
|
-
)
|
|
98
|
-
except Exception as e:
|
|
99
|
-
print(f"Error creating warning: {e}")
|
|
100
|
-
return None
|
|
59
|
+
county = area.get("name", "Unknown")
|
|
60
|
+
phenomenon = alert.get("phenomenon", "")
|
|
61
|
+
severity = alert.get("severity", "Minor")
|
|
101
62
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
63
|
+
warning_type = re.sub(r"^(dangerous|severe|extreme)-", "", phenomenon)
|
|
64
|
+
|
|
65
|
+
desc_dict = alert.get("description", {})
|
|
66
|
+
inst_dict = alert.get("instruction", {})
|
|
67
|
+
|
|
68
|
+
description = desc_dict.get("en") or desc_dict.get("lt", "")
|
|
69
|
+
instruction = inst_dict.get("en") or inst_dict.get("lt", "")
|
|
70
|
+
|
|
71
|
+
full_description = description
|
|
72
|
+
if instruction:
|
|
73
|
+
full_description += f"\n\nRecommendations: {instruction}"
|
|
74
|
+
|
|
75
|
+
return WeatherWarning(
|
|
76
|
+
county=county,
|
|
77
|
+
warning_type=warning_type,
|
|
78
|
+
severity=severity,
|
|
79
|
+
description=full_description,
|
|
80
|
+
start_time=alert.get("t_from"),
|
|
81
|
+
end_time=alert.get("t_to"),
|
|
110
82
|
)
|
|
111
83
|
|
|
84
|
+
def _warning_affects_area(self, warning: WeatherWarning, administrative_division: str) -> bool:
|
|
85
|
+
"""Check if warning affects specified administrative division"""
|
|
86
|
+
admin_lower = administrative_division.lower().replace(" savivaldybė", "").replace(" sav.", "")
|
|
87
|
+
|
|
112
88
|
# Check if the administrative division matches the warning county
|
|
113
89
|
if admin_lower in warning.county.lower():
|
|
114
90
|
return True
|
|
@@ -117,28 +93,20 @@ class WeatherWarningsProcessor:
|
|
|
117
93
|
if warning.county in COUNTY_MUNICIPALITIES:
|
|
118
94
|
municipalities = COUNTY_MUNICIPALITIES[warning.county]
|
|
119
95
|
for municipality in municipalities:
|
|
120
|
-
mun_clean = (
|
|
121
|
-
municipality.lower()
|
|
122
|
-
.replace(" savivaldybė", "")
|
|
123
|
-
.replace(" sav.", "")
|
|
124
|
-
)
|
|
96
|
+
mun_clean = municipality.lower().replace(" savivaldybė", "").replace(" sav.", "")
|
|
125
97
|
if admin_lower in mun_clean or mun_clean in admin_lower:
|
|
126
98
|
return True
|
|
127
99
|
|
|
128
100
|
return False
|
|
129
101
|
|
|
130
|
-
def enrich_forecast_with_warnings(
|
|
131
|
-
self, forecast: Forecast, warnings: List[WeatherWarning]
|
|
132
|
-
):
|
|
102
|
+
def enrich_forecast_with_warnings(self, forecast: Forecast, warnings: List[WeatherWarning]) -> None:
|
|
133
103
|
"""Enrich forecast timestamps with relevant weather warnings"""
|
|
134
104
|
if not warnings:
|
|
135
105
|
return
|
|
136
106
|
|
|
137
107
|
# For each forecast timestamp, find applicable warnings
|
|
138
108
|
for timestamp in forecast.forecast_timestamps:
|
|
139
|
-
timestamp.warnings = self._get_warnings_for_timestamp(
|
|
140
|
-
timestamp.datetime, warnings
|
|
141
|
-
)
|
|
109
|
+
timestamp.warnings = self._get_warnings_for_timestamp(timestamp.datetime, warnings)
|
|
142
110
|
|
|
143
111
|
# Also add warnings to current conditions if available
|
|
144
112
|
if hasattr(forecast, "current_conditions") and forecast.current_conditions:
|
|
@@ -146,14 +114,10 @@ class WeatherWarningsProcessor:
|
|
|
146
114
|
forecast.current_conditions.datetime, warnings
|
|
147
115
|
)
|
|
148
116
|
|
|
149
|
-
def _get_warnings_for_timestamp(
|
|
150
|
-
self, timestamp_str: str, warnings: List[WeatherWarning]
|
|
151
|
-
) -> List[WeatherWarning]:
|
|
117
|
+
def _get_warnings_for_timestamp(self, timestamp_str: str, warnings: List[WeatherWarning]) -> List[WeatherWarning]:
|
|
152
118
|
"""Get warnings that are active for a specific timestamp"""
|
|
153
119
|
try:
|
|
154
|
-
timestamp = datetime.fromisoformat(timestamp_str).replace(
|
|
155
|
-
tzinfo=timezone.utc
|
|
156
|
-
)
|
|
120
|
+
timestamp = datetime.fromisoformat(timestamp_str).replace(tzinfo=timezone.utc)
|
|
157
121
|
applicable_warnings = []
|
|
158
122
|
|
|
159
123
|
for warning in warnings:
|
|
@@ -161,12 +125,8 @@ class WeatherWarningsProcessor:
|
|
|
161
125
|
continue
|
|
162
126
|
|
|
163
127
|
try:
|
|
164
|
-
start_time = datetime.fromisoformat(
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
end_time = datetime.fromisoformat(
|
|
168
|
-
warning.end_time.replace("Z", "+00:00")
|
|
169
|
-
)
|
|
128
|
+
start_time = datetime.fromisoformat(warning.start_time.replace("Z", "+00:00"))
|
|
129
|
+
end_time = datetime.fromisoformat(warning.end_time.replace("Z", "+00:00"))
|
|
170
130
|
|
|
171
131
|
# Check if timestamp falls within warning period
|
|
172
132
|
if start_time <= timestamp <= end_time:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meteo_lt-pkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0b0
|
|
4
4
|
Summary: A library to fetch weather data from api.meteo.lt
|
|
5
5
|
Author-email: Brunas <brunonas@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/Brunas/meteo_lt-pkg
|
|
@@ -12,17 +12,17 @@ Classifier: Development Status :: 4 - Beta
|
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
|
-
Requires-Dist: aiohttp
|
|
15
|
+
Requires-Dist: aiohttp>=3.13
|
|
16
16
|
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: pytest; extra == "dev"
|
|
18
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
|
-
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
20
|
-
Requires-Dist: black; extra == "dev"
|
|
21
|
-
Requires-Dist: coverage; extra == "dev"
|
|
22
|
-
Requires-Dist: flake8; extra == "dev"
|
|
23
|
-
Requires-Dist: pyflakes; extra == "dev"
|
|
24
|
-
Requires-Dist: pylint; extra == "dev"
|
|
25
|
-
Requires-Dist: build; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
|
|
20
|
+
Requires-Dist: black>=26.0; extra == "dev"
|
|
21
|
+
Requires-Dist: coverage>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pyflakes>=3.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pylint>=4.0; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
26
26
|
Dynamic: license-file
|
|
27
27
|
|
|
28
28
|
# Meteo.Lt Lithuanian weather forecast package
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "meteo_lt-pkg"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0-beta"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="Brunas", email="brunonas@gmail.com" },
|
|
6
6
|
]
|
|
@@ -14,7 +14,7 @@ classifiers = [
|
|
|
14
14
|
"Development Status :: 4 - Beta",
|
|
15
15
|
]
|
|
16
16
|
dependencies = [
|
|
17
|
-
"aiohttp",
|
|
17
|
+
"aiohttp>=3.13",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.urls]
|
|
@@ -23,17 +23,20 @@ Issues = "https://github.com/Brunas/meteo_lt-pkg/issues"
|
|
|
23
23
|
|
|
24
24
|
[project.optional-dependencies]
|
|
25
25
|
dev = [
|
|
26
|
-
"pytest",
|
|
27
|
-
"pytest-cov",
|
|
28
|
-
"pytest-asyncio",
|
|
29
|
-
"black",
|
|
30
|
-
"coverage",
|
|
31
|
-
"flake8",
|
|
32
|
-
"pyflakes",
|
|
33
|
-
"pylint",
|
|
34
|
-
"build"
|
|
26
|
+
"pytest>=9.0",
|
|
27
|
+
"pytest-cov>=7.0",
|
|
28
|
+
"pytest-asyncio>=1.3",
|
|
29
|
+
"black>=26.0",
|
|
30
|
+
"coverage>=7.0",
|
|
31
|
+
"flake8>=7.0",
|
|
32
|
+
"pyflakes>=3.0",
|
|
33
|
+
"pylint>=4.0",
|
|
34
|
+
"build>=1.0"
|
|
35
35
|
]
|
|
36
36
|
|
|
37
|
+
[tool.black]
|
|
38
|
+
line-length = 120
|
|
39
|
+
|
|
37
40
|
[build-system]
|
|
38
41
|
requires = ["setuptools>=61.0", "wheel"]
|
|
39
42
|
build-backend = "setuptools.build_meta"
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""init.py"""
|
|
2
|
-
|
|
3
|
-
from .api import MeteoLtAPI
|
|
4
|
-
from .models import Coordinates, Place, ForecastTimestamp, Forecast, WeatherWarning
|
|
5
|
-
|
|
6
|
-
__all__ = [
|
|
7
|
-
"MeteoLtAPI",
|
|
8
|
-
"Coordinates",
|
|
9
|
-
"Place",
|
|
10
|
-
"ForecastTimestamp",
|
|
11
|
-
"Forecast",
|
|
12
|
-
"WeatherWarning",
|
|
13
|
-
]
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"""Main API class script"""
|
|
2
|
-
|
|
3
|
-
# pylint: disable=W0718
|
|
4
|
-
|
|
5
|
-
from typing import List
|
|
6
|
-
|
|
7
|
-
from .models import Forecast, WeatherWarning
|
|
8
|
-
from .utils import find_nearest_place
|
|
9
|
-
from .client import MeteoLtClient
|
|
10
|
-
from .warnings import WeatherWarningsProcessor
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class MeteoLtAPI:
|
|
14
|
-
"""Main API class that orchestrates external API calls and warning processing"""
|
|
15
|
-
|
|
16
|
-
def __init__(self, session=None):
|
|
17
|
-
self.places = []
|
|
18
|
-
self.client = MeteoLtClient(session)
|
|
19
|
-
self.warnings_processor = WeatherWarningsProcessor(self.client)
|
|
20
|
-
|
|
21
|
-
async def __aenter__(self):
|
|
22
|
-
"""Async context manager entry"""
|
|
23
|
-
await self.client.__aenter__()
|
|
24
|
-
return self
|
|
25
|
-
|
|
26
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
27
|
-
"""Async context manager exit"""
|
|
28
|
-
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
|
29
|
-
|
|
30
|
-
async def close(self):
|
|
31
|
-
"""Close the API client and cleanup resources"""
|
|
32
|
-
await self.client.close()
|
|
33
|
-
|
|
34
|
-
async def fetch_places(self):
|
|
35
|
-
"""Gets all places from API"""
|
|
36
|
-
self.places = await self.client.fetch_places()
|
|
37
|
-
|
|
38
|
-
async def get_nearest_place(self, latitude, longitude):
|
|
39
|
-
"""Finds nearest place using provided coordinates"""
|
|
40
|
-
if not self.places:
|
|
41
|
-
await self.fetch_places()
|
|
42
|
-
return find_nearest_place(latitude, longitude, self.places)
|
|
43
|
-
|
|
44
|
-
async def get_forecast_with_warnings(
|
|
45
|
-
self, latitude=None, longitude=None, place_code=None
|
|
46
|
-
):
|
|
47
|
-
"""Get forecast with weather warnings for a location"""
|
|
48
|
-
if place_code is None:
|
|
49
|
-
if latitude is None or longitude is None:
|
|
50
|
-
raise ValueError(
|
|
51
|
-
"Either place_code or both latitude and longitude must be provided"
|
|
52
|
-
)
|
|
53
|
-
place = await self.get_nearest_place(latitude, longitude)
|
|
54
|
-
place_code = place.code
|
|
55
|
-
|
|
56
|
-
return await self.get_forecast(place_code, include_warnings=True)
|
|
57
|
-
|
|
58
|
-
async def get_forecast(self, place_code, include_warnings=True):
|
|
59
|
-
"""Retrieves forecast data from API"""
|
|
60
|
-
forecast = await self.client.fetch_forecast(place_code)
|
|
61
|
-
|
|
62
|
-
if include_warnings:
|
|
63
|
-
await self._enrich_forecast_with_warnings(forecast)
|
|
64
|
-
|
|
65
|
-
return forecast
|
|
66
|
-
|
|
67
|
-
async def get_weather_warnings(
|
|
68
|
-
self, administrative_division: str = None
|
|
69
|
-
) -> List[WeatherWarning]:
|
|
70
|
-
"""Fetches weather warnings from meteo.lt JSON API"""
|
|
71
|
-
return await self.warnings_processor.get_weather_warnings(
|
|
72
|
-
administrative_division
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
async def _enrich_forecast_with_warnings(self, forecast: Forecast):
|
|
76
|
-
"""Enrich forecast timestamps with relevant weather warnings"""
|
|
77
|
-
if (
|
|
78
|
-
not forecast
|
|
79
|
-
or not forecast.place
|
|
80
|
-
or not forecast.place.administrative_division
|
|
81
|
-
):
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
warnings = await self.get_weather_warnings(
|
|
86
|
-
forecast.place.administrative_division
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
if warnings:
|
|
90
|
-
self.warnings_processor.enrich_forecast_with_warnings(
|
|
91
|
-
forecast, warnings
|
|
92
|
-
)
|
|
93
|
-
except Exception as e:
|
|
94
|
-
print(f"Warning: Could not fetch weather warnings: {e}")
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""MeteoLt API client for external API calls"""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import List, Optional
|
|
5
|
-
|
|
6
|
-
import aiohttp
|
|
7
|
-
|
|
8
|
-
from .models import Place, Forecast
|
|
9
|
-
from .const import BASE_URL, WARNINGS_URL, TIMEOUT, ENCODING
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class MeteoLtClient:
|
|
13
|
-
"""Client for external API calls to meteo.lt"""
|
|
14
|
-
|
|
15
|
-
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
|
|
16
|
-
self._session = session
|
|
17
|
-
self._owns_session = session is None
|
|
18
|
-
|
|
19
|
-
async def __aenter__(self):
|
|
20
|
-
"""Async context manager entry"""
|
|
21
|
-
if self._session is None:
|
|
22
|
-
self._session = aiohttp.ClientSession(
|
|
23
|
-
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
|
|
24
|
-
raise_for_status=True,
|
|
25
|
-
)
|
|
26
|
-
return self
|
|
27
|
-
|
|
28
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
29
|
-
"""Async context manager exit"""
|
|
30
|
-
await self.close()
|
|
31
|
-
|
|
32
|
-
async def close(self):
|
|
33
|
-
"""Close the client session if we own it"""
|
|
34
|
-
if self._session and self._owns_session:
|
|
35
|
-
await self._session.close()
|
|
36
|
-
self._session = None
|
|
37
|
-
|
|
38
|
-
async def _get_session(self) -> aiohttp.ClientSession:
|
|
39
|
-
"""Get or create a session"""
|
|
40
|
-
if self._session is None:
|
|
41
|
-
self._session = aiohttp.ClientSession(
|
|
42
|
-
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
|
|
43
|
-
raise_for_status=True,
|
|
44
|
-
)
|
|
45
|
-
return self._session
|
|
46
|
-
|
|
47
|
-
async def fetch_places(self) -> List[Place]:
|
|
48
|
-
"""Gets all places from API"""
|
|
49
|
-
session = await self._get_session()
|
|
50
|
-
async with session.get(f"{BASE_URL}/places") as response:
|
|
51
|
-
response.encoding = ENCODING
|
|
52
|
-
response_json = await response.json()
|
|
53
|
-
return [Place.from_dict(place) for place in response_json]
|
|
54
|
-
|
|
55
|
-
async def fetch_forecast(self, place_code: str) -> Forecast:
|
|
56
|
-
"""Retrieves forecast data from API"""
|
|
57
|
-
session = await self._get_session()
|
|
58
|
-
async with session.get(
|
|
59
|
-
f"{BASE_URL}/places/{place_code}/forecasts/long-term"
|
|
60
|
-
) as response:
|
|
61
|
-
response.encoding = ENCODING
|
|
62
|
-
response_json = await response.json()
|
|
63
|
-
return Forecast.from_dict(response_json)
|
|
64
|
-
|
|
65
|
-
async def fetch_weather_warnings(self) -> List[dict]:
|
|
66
|
-
"""Fetches raw weather warnings data from meteo.lt JSON API"""
|
|
67
|
-
session = await self._get_session()
|
|
68
|
-
|
|
69
|
-
# Get the latest warnings file
|
|
70
|
-
async with session.get(WARNINGS_URL) as response:
|
|
71
|
-
file_list = await response.json()
|
|
72
|
-
|
|
73
|
-
if not file_list:
|
|
74
|
-
return []
|
|
75
|
-
|
|
76
|
-
# Fetch the latest warnings data
|
|
77
|
-
latest_file_url = file_list[0] # First file is the most recent
|
|
78
|
-
async with session.get(latest_file_url) as response:
|
|
79
|
-
text_data = await response.text()
|
|
80
|
-
return json.loads(text_data)
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""utils.py"""
|
|
2
|
-
|
|
3
|
-
from math import radians, sin, cos, sqrt, atan2
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def haversine(lat1, lon1, lat2, lon2):
|
|
7
|
-
"""Calculate the great-circle distance between two points on the Earth's surface."""
|
|
8
|
-
# Convert latitude and longitude from degrees to radians
|
|
9
|
-
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
10
|
-
|
|
11
|
-
# Haversine formula
|
|
12
|
-
dlat = lat2 - lat1
|
|
13
|
-
dlon = lon2 - lon1
|
|
14
|
-
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
15
|
-
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
16
|
-
r = 6371 # Radius of Earth in kilometers
|
|
17
|
-
return r * c
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def find_nearest_place(latitude, longitude, places):
|
|
21
|
-
"""Find the nearest place from a list of places based on the given latitude and longitude."""
|
|
22
|
-
nearest_place = None
|
|
23
|
-
min_distance = float("inf")
|
|
24
|
-
|
|
25
|
-
for place in places:
|
|
26
|
-
place_lat = place.latitude
|
|
27
|
-
place_lon = place.longitude
|
|
28
|
-
distance = haversine(latitude, longitude, place_lat, place_lon)
|
|
29
|
-
|
|
30
|
-
if distance < min_distance:
|
|
31
|
-
min_distance = distance
|
|
32
|
-
nearest_place = place
|
|
33
|
-
|
|
34
|
-
return nearest_place
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|