meteo-lt-pkg 0.5.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meteo_lt-pkg
3
- Version: 0.5.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 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,13 +33,19 @@ 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
 
@@ -60,6 +61,32 @@ class WeatherWarning:
60
61
  end_time: Optional[str] = None
61
62
 
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
+
63
90
  @dataclass
64
91
  class ForecastTimestamp:
65
92
  """ForecastTimestamp"""
@@ -85,16 +112,12 @@ class Forecast:
85
112
  place: Place
86
113
  forecast_created: str = field(metadata={"json_key": "forecastCreationTimeUtc"})
87
114
  current_conditions: ForecastTimestamp
88
- forecast_timestamps: List[ForecastTimestamp] = field(
89
- metadata={"json_key": "forecastTimestamps"}
90
- )
115
+ forecast_timestamps: List[ForecastTimestamp] = field(metadata={"json_key": "forecastTimestamps"})
91
116
 
92
117
  def __post_init__(self):
93
118
  """Post-initialization processing."""
94
119
 
95
- current_hour = datetime.now(timezone.utc).replace(
96
- minute=0, second=0, microsecond=0
97
- )
120
+ current_hour = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
98
121
  # Current conditions are equal to current hour record
99
122
  for forecast in self.forecast_timestamps:
100
123
  if (
@@ -118,9 +141,9 @@ class Forecast:
118
141
  ]
119
142
 
120
143
 
121
- def from_dict(cls, data: dict):
144
+ def from_dict(cls: Type, data: Dict[str, Any]) -> Any:
122
145
  """Utility function to convert a dictionary to a dataclass instance."""
123
- init_args = {}
146
+ init_args: Dict[str, Any] = {}
124
147
  for f in fields(cls):
125
148
  if not f.init:
126
149
  continue # Skip fields that are not part of the constructor
@@ -133,11 +156,9 @@ def from_dict(cls, data: dict):
133
156
  value = from_dict(f.type, value)
134
157
  elif isinstance(value, list) and hasattr(f.type.__args__[0], "from_dict"):
135
158
  value = [from_dict(f.type.__args__[0], item) for item in value]
136
- elif f.name in ("datetime", "forecast_created"):
159
+ elif f.name in ("datetime", "forecast_created", "observation_datetime") and value:
137
160
  # Convert datetime to ISO 8601 format
138
- dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(
139
- tzinfo=timezone.utc
140
- )
161
+ dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
141
162
  value = dt.isoformat()
142
163
 
143
164
  init_args[f.name] = value
@@ -149,3 +170,6 @@ 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
  from datetime import datetime, timezone
5
- from typing import List
5
+ from typing import List, Optional, Dict, Any
6
6
 
7
7
  from .models import Forecast, WeatherWarning
8
8
  from .const import COUNTY_MUNICIPALITIES
@@ -15,24 +15,18 @@ class WeatherWarningsProcessor:
15
15
  def __init__(self, client: MeteoLtClient):
16
16
  self.client = client
17
17
 
18
- async def get_weather_warnings(
19
- self, administrative_division: str = None
20
- ) -> List[WeatherWarning]:
18
+ async def get_weather_warnings(self, administrative_division: str = None) -> List[WeatherWarning]:
21
19
  """Fetches and processes weather warnings"""
22
20
  warnings_data = await self.client.fetch_weather_warnings()
23
21
  warnings = self._parse_warnings_data(warnings_data)
24
22
 
25
23
  # Filter by administrative division if specified
26
24
  if administrative_division:
27
- warnings = [
28
- w
29
- for w in warnings
30
- if self._warning_affects_area(w, administrative_division)
31
- ]
25
+ warnings = [w for w in warnings if self._warning_affects_area(w, administrative_division)]
32
26
 
33
27
  return warnings
34
28
 
35
- def _parse_warnings_data(self, warnings_data: dict) -> List[WeatherWarning]:
29
+ def _parse_warnings_data(self, warnings_data: Optional[Dict[str, Any]]) -> List[WeatherWarning]:
36
30
  """Parse raw warnings data into WeatherWarning objects"""
37
31
  warnings = []
38
32
 
@@ -49,9 +43,7 @@ class WeatherWarningsProcessor:
49
43
  for area_group in phenomenon_group.get("area_groups", []):
50
44
  for alert in area_group.get("single_alerts", []):
51
45
  # Skip alerts with no phenomenon or empty descriptions
52
- if not alert.get("phenomenon") or not alert.get(
53
- "description", {}
54
- ).get("lt"):
46
+ if not alert.get("phenomenon") or not alert.get("description", {}).get("lt"):
55
47
  continue
56
48
 
57
49
  # Create warnings for each area in the group
@@ -62,7 +54,7 @@ class WeatherWarningsProcessor:
62
54
 
63
55
  return warnings
64
56
 
65
- 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:
66
58
  """Create a WeatherWarning from alert data"""
67
59
  county = area.get("name", "Unknown")
68
60
  phenomenon = alert.get("phenomenon", "")
@@ -89,15 +81,9 @@ class WeatherWarningsProcessor:
89
81
  end_time=alert.get("t_to"),
90
82
  )
91
83
 
92
- def _warning_affects_area(
93
- self, warning: WeatherWarning, administrative_division: str
94
- ) -> bool:
84
+ def _warning_affects_area(self, warning: WeatherWarning, administrative_division: str) -> bool:
95
85
  """Check if warning affects specified administrative division"""
96
- admin_lower = (
97
- administrative_division.lower()
98
- .replace(" savivaldybė", "")
99
- .replace(" sav.", "")
100
- )
86
+ admin_lower = administrative_division.lower().replace(" savivaldybė", "").replace(" sav.", "")
101
87
 
102
88
  # Check if the administrative division matches the warning county
103
89
  if admin_lower in warning.county.lower():
@@ -107,28 +93,20 @@ class WeatherWarningsProcessor:
107
93
  if warning.county in COUNTY_MUNICIPALITIES:
108
94
  municipalities = COUNTY_MUNICIPALITIES[warning.county]
109
95
  for municipality in municipalities:
110
- mun_clean = (
111
- municipality.lower()
112
- .replace(" savivaldybė", "")
113
- .replace(" sav.", "")
114
- )
96
+ mun_clean = municipality.lower().replace(" savivaldybė", "").replace(" sav.", "")
115
97
  if admin_lower in mun_clean or mun_clean in admin_lower:
116
98
  return True
117
99
 
118
100
  return False
119
101
 
120
- def enrich_forecast_with_warnings(
121
- self, forecast: Forecast, warnings: List[WeatherWarning]
122
- ):
102
+ def enrich_forecast_with_warnings(self, forecast: Forecast, warnings: List[WeatherWarning]) -> None:
123
103
  """Enrich forecast timestamps with relevant weather warnings"""
124
104
  if not warnings:
125
105
  return
126
106
 
127
107
  # For each forecast timestamp, find applicable warnings
128
108
  for timestamp in forecast.forecast_timestamps:
129
- timestamp.warnings = self._get_warnings_for_timestamp(
130
- timestamp.datetime, warnings
131
- )
109
+ timestamp.warnings = self._get_warnings_for_timestamp(timestamp.datetime, warnings)
132
110
 
133
111
  # Also add warnings to current conditions if available
134
112
  if hasattr(forecast, "current_conditions") and forecast.current_conditions:
@@ -136,14 +114,10 @@ class WeatherWarningsProcessor:
136
114
  forecast.current_conditions.datetime, warnings
137
115
  )
138
116
 
139
- def _get_warnings_for_timestamp(
140
- self, timestamp_str: str, warnings: List[WeatherWarning]
141
- ) -> List[WeatherWarning]:
117
+ def _get_warnings_for_timestamp(self, timestamp_str: str, warnings: List[WeatherWarning]) -> List[WeatherWarning]:
142
118
  """Get warnings that are active for a specific timestamp"""
143
119
  try:
144
- timestamp = datetime.fromisoformat(timestamp_str).replace(
145
- tzinfo=timezone.utc
146
- )
120
+ timestamp = datetime.fromisoformat(timestamp_str).replace(tzinfo=timezone.utc)
147
121
  applicable_warnings = []
148
122
 
149
123
  for warning in warnings:
@@ -151,12 +125,8 @@ class WeatherWarningsProcessor:
151
125
  continue
152
126
 
153
127
  try:
154
- start_time = datetime.fromisoformat(
155
- warning.start_time.replace("Z", "+00:00")
156
- )
157
- end_time = datetime.fromisoformat(
158
- warning.end_time.replace("Z", "+00:00")
159
- )
128
+ start_time = datetime.fromisoformat(warning.start_time.replace("Z", "+00:00"))
129
+ end_time = datetime.fromisoformat(warning.end_time.replace("Z", "+00:00"))
160
130
 
161
131
  # Check if timestamp falls within warning period
162
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.5.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,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.5.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,87 +0,0 @@
1
- """Main API class script"""
2
-
3
- from typing import List
4
-
5
- from .models import Forecast, WeatherWarning
6
- from .utils import find_nearest_place
7
- from .client import MeteoLtClient
8
- from .warnings import WeatherWarningsProcessor
9
-
10
-
11
- class MeteoLtAPI:
12
- """Main API class that orchestrates external API calls and warning processing"""
13
-
14
- def __init__(self, session=None):
15
- self.places = []
16
- self.client = MeteoLtClient(session)
17
- self.warnings_processor = WeatherWarningsProcessor(self.client)
18
-
19
- async def __aenter__(self):
20
- """Async context manager entry"""
21
- await self.client.__aenter__()
22
- return self
23
-
24
- async def __aexit__(self, exc_type, exc_val, exc_tb):
25
- """Async context manager exit"""
26
- await self.client.__aexit__(exc_type, exc_val, exc_tb)
27
-
28
- async def close(self):
29
- """Close the API client and cleanup resources"""
30
- await self.client.close()
31
-
32
- async def fetch_places(self):
33
- """Gets all places from API"""
34
- self.places = await self.client.fetch_places()
35
-
36
- async def get_nearest_place(self, latitude, longitude):
37
- """Finds nearest place using provided coordinates"""
38
- if not self.places:
39
- await self.fetch_places()
40
- return find_nearest_place(latitude, longitude, self.places)
41
-
42
- async def get_forecast_with_warnings(
43
- self, latitude=None, longitude=None, place_code=None
44
- ):
45
- """Get forecast with weather warnings for a location"""
46
- if place_code is None:
47
- if latitude is None or longitude is None:
48
- raise ValueError(
49
- "Either place_code or both latitude and longitude must be provided"
50
- )
51
- place = await self.get_nearest_place(latitude, longitude)
52
- place_code = place.code
53
-
54
- return await self.get_forecast(place_code, include_warnings=True)
55
-
56
- async def get_forecast(self, place_code, include_warnings=True):
57
- """Retrieves forecast data from API"""
58
- forecast = await self.client.fetch_forecast(place_code)
59
-
60
- if include_warnings:
61
- await self._enrich_forecast_with_warnings(forecast)
62
-
63
- return forecast
64
-
65
- async def get_weather_warnings(
66
- self, administrative_division: str = None
67
- ) -> List[WeatherWarning]:
68
- """Fetches weather warnings from meteo.lt JSON API"""
69
- return await self.warnings_processor.get_weather_warnings(
70
- administrative_division
71
- )
72
-
73
- async def _enrich_forecast_with_warnings(self, forecast: Forecast):
74
- """Enrich forecast timestamps with relevant weather warnings"""
75
- if (
76
- not forecast
77
- or not forecast.place
78
- or not forecast.place.administrative_division
79
- ):
80
- return
81
-
82
- warnings = await self.get_weather_warnings(
83
- forecast.place.administrative_division
84
- )
85
-
86
- if warnings:
87
- self.warnings_processor.enrich_forecast_with_warnings(forecast, warnings)
@@ -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
File without changes