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.
Files changed (23) hide show
  1. {meteo_lt_pkg-0.4.0b1/meteo_lt_pkg.egg-info → meteo_lt_pkg-0.5.0b0}/PKG-INFO +11 -11
  2. meteo_lt_pkg-0.5.0b0/meteo_lt/__init__.py +27 -0
  3. meteo_lt_pkg-0.5.0b0/meteo_lt/api.py +109 -0
  4. meteo_lt_pkg-0.5.0b0/meteo_lt/client.py +163 -0
  5. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/const.py +1 -4
  6. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/models.py +61 -37
  7. meteo_lt_pkg-0.5.0b0/meteo_lt/utils.py +37 -0
  8. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt/warnings.py +39 -79
  9. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0/meteo_lt_pkg.egg-info}/PKG-INFO +11 -11
  10. meteo_lt_pkg-0.5.0b0/meteo_lt_pkg.egg-info/requires.txt +12 -0
  11. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/pyproject.toml +14 -11
  12. meteo_lt_pkg-0.4.0b1/meteo_lt/__init__.py +0 -13
  13. meteo_lt_pkg-0.4.0b1/meteo_lt/api.py +0 -94
  14. meteo_lt_pkg-0.4.0b1/meteo_lt/client.py +0 -80
  15. meteo_lt_pkg-0.4.0b1/meteo_lt/utils.py +0 -34
  16. meteo_lt_pkg-0.4.0b1/meteo_lt_pkg.egg-info/requires.txt +0 -12
  17. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/LICENSE +0 -0
  18. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/MANIFEST.in +0 -0
  19. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/README.md +0 -0
  20. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/SOURCES.txt +0 -0
  21. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/dependency_links.txt +0 -0
  22. {meteo_lt_pkg-0.4.0b1 → meteo_lt_pkg-0.5.0b0}/meteo_lt_pkg.egg-info/top_level.txt +0 -0
  23. {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.4.0b1
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 Place:
20
- """Places"""
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["WeatherWarning"] = field(default_factory=list, init=False)
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: dict):
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: dict) -> List[WeatherWarning]:
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: dict, area: dict) -> WeatherWarning:
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
- try:
70
- county = area.get("name", "Unknown")
71
- phenomenon = alert.get("phenomenon", "")
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
- def _warning_affects_area(
103
- self, warning: WeatherWarning, administrative_division: str
104
- ) -> bool:
105
- """Check if warning affects specified administrative division"""
106
- admin_lower = (
107
- administrative_division.lower()
108
- .replace(" savivaldybė", "")
109
- .replace(" sav.", "")
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
- warning.start_time.replace("Z", "+00:00")
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.4.0b1
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,12 @@
1
+ aiohttp>=3.13
2
+
3
+ [dev]
4
+ pytest>=9.0
5
+ pytest-cov>=7.0
6
+ pytest-asyncio>=1.3
7
+ black>=26.0
8
+ coverage>=7.0
9
+ flake8>=7.0
10
+ pyflakes>=3.0
11
+ pylint>=4.0
12
+ build>=1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "meteo_lt-pkg"
3
- version = "0.4.0-beta1"
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
@@ -1,12 +0,0 @@
1
- aiohttp
2
-
3
- [dev]
4
- pytest
5
- pytest-cov
6
- pytest-asyncio
7
- black
8
- coverage
9
- flake8
10
- pyflakes
11
- pylint
12
- build
File without changes
File without changes
File without changes