wevva 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. wevva/__init__.py +50 -0
  2. wevva/__main__.py +11 -0
  3. wevva/api.py +281 -0
  4. wevva/app.py +186 -0
  5. wevva/cli.py +654 -0
  6. wevva/conditions.py +54 -0
  7. wevva/config.py +330 -0
  8. wevva/constants.py +34 -0
  9. wevva/controller.py +113 -0
  10. wevva/location_metadata.py +36 -0
  11. wevva/messages.py +70 -0
  12. wevva/models.py +24 -0
  13. wevva/openmeteo.py +696 -0
  14. wevva/screens/air_quality_help.py +117 -0
  15. wevva/screens/author_screen.py +83 -0
  16. wevva/screens/help.py +89 -0
  17. wevva/screens/search_screen.py +54 -0
  18. wevva/screens/settings_screen.py +143 -0
  19. wevva/screens/weather_screen.py +280 -0
  20. wevva/services/air_quality.py +39 -0
  21. wevva/services/geocoding.py +89 -0
  22. wevva/services/weather.py +42 -0
  23. wevva/utils/__init__.py +37 -0
  24. wevva/utils/colors.py +259 -0
  25. wevva/utils/formatting.py +90 -0
  26. wevva/utils/geo.py +49 -0
  27. wevva/utils/visualization.py +61 -0
  28. wevva/wevva.tcss +19 -0
  29. wevva/widgets/air_quality.py +212 -0
  30. wevva/widgets/astronomy_info.py +240 -0
  31. wevva/widgets/context_bar.py +121 -0
  32. wevva/widgets/current_conditions.py +147 -0
  33. wevva/widgets/current_detail.py +137 -0
  34. wevva/widgets/daily_forecast.py +82 -0
  35. wevva/widgets/daily_summary.py +192 -0
  36. wevva/widgets/hourly_forecast.py +421 -0
  37. wevva/widgets/location_info.py +234 -0
  38. wevva/widgets/precip_info.py +96 -0
  39. wevva/widgets/search_dialog.py +173 -0
  40. wevva/widgets/search_results.py +122 -0
  41. wevva/widgets/weather_summary.py +168 -0
  42. wevva/widgets/weather_widget.py +142 -0
  43. wevva-0.1.0.dist-info/METADATA +194 -0
  44. wevva-0.1.0.dist-info/RECORD +47 -0
  45. wevva-0.1.0.dist-info/WHEEL +4 -0
  46. wevva-0.1.0.dist-info/entry_points.txt +2 -0
  47. wevva-0.1.0.dist-info/licenses/LICENSE +21 -0
wevva/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Wevva package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from wevva.api import (
6
+ LocationNotFoundError,
7
+ WevvaAPIError,
8
+ forecast_by_coordinates,
9
+ forecast_by_coordinates_sync,
10
+ forecast_by_place,
11
+ forecast_by_place_sync,
12
+ geocode,
13
+ geocode_sync,
14
+ )
15
+ from wevva.location_metadata import LocationMetadata
16
+ from wevva.models import ForecastBundle
17
+ from wevva.openmeteo import (
18
+ CurrentOpenMeteoForecast,
19
+ DailyOpenMeteoForecast,
20
+ HourlyOpenMeteoForecast,
21
+ )
22
+
23
+ CurrentForecast = CurrentOpenMeteoForecast
24
+ HourlyForecast = HourlyOpenMeteoForecast
25
+ DailyForecast = DailyOpenMeteoForecast
26
+
27
+ __all__ = [
28
+ "CurrentForecast",
29
+ "CurrentOpenMeteoForecast",
30
+ "DailyForecast",
31
+ "DailyOpenMeteoForecast",
32
+ "ForecastBundle",
33
+ "HourlyForecast",
34
+ "HourlyOpenMeteoForecast",
35
+ "LocationMetadata",
36
+ "LocationNotFoundError",
37
+ "WevvaAPIError",
38
+ "__version__",
39
+ "forecast_by_coordinates",
40
+ "forecast_by_coordinates_sync",
41
+ "forecast_by_place",
42
+ "forecast_by_place_sync",
43
+ "geocode",
44
+ "geocode_sync",
45
+ ]
46
+
47
+ try:
48
+ __version__ = version("wevva")
49
+ except PackageNotFoundError:
50
+ __version__ = "0.0.0"
wevva/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Run Wevva via `python -m wevva`."""
2
+
3
+ from wevva.cli import app
4
+
5
+
6
+ def main() -> None:
7
+ app()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
wevva/api.py ADDED
@@ -0,0 +1,281 @@
1
+ """Public library API for fetching weather without the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from copy import deepcopy
7
+ from typing import Any, Awaitable, TypeVar
8
+
9
+ from wevva.location_metadata import LocationMetadata
10
+ from wevva.models import ForecastBundle
11
+ from wevva.openmeteo import (
12
+ CurrentOpenMeteoForecast,
13
+ DailyOpenMeteoForecast,
14
+ HourlyOpenMeteoForecast,
15
+ OpenMeteoForecast,
16
+ )
17
+ from wevva.services.air_quality import fetch_air_quality
18
+ from wevva.services.geocoding import search_places
19
+ from wevva.services.weather import fetch_weather
20
+
21
+ AIR_QUALITY_FIELDS: tuple[str, ...] = (
22
+ "us_aqi",
23
+ "european_aqi",
24
+ "pm2_5",
25
+ "pm10",
26
+ "ozone",
27
+ "grass_pollen",
28
+ )
29
+ T = TypeVar("T")
30
+
31
+
32
+ class WevvaAPIError(RuntimeError):
33
+ """Base exception for public API helpers."""
34
+
35
+
36
+ class LocationNotFoundError(WevvaAPIError):
37
+ """Raised when a place query returns no geocoding matches."""
38
+
39
+
40
+ def _run_sync(coro: Awaitable[T]) -> T:
41
+ """Run an async coroutine from synchronous code."""
42
+ try:
43
+ asyncio.get_running_loop()
44
+ except RuntimeError:
45
+ return asyncio.run(coro)
46
+ raise WevvaAPIError(
47
+ "Sync API cannot run inside an active event loop. Use async API functions instead."
48
+ )
49
+
50
+
51
+ def _location_metadata_from_place(place: dict[str, Any]) -> LocationMetadata:
52
+ """Convert one geocoding result into ``LocationMetadata``."""
53
+ lat = place.get("latitude")
54
+ lon = place.get("longitude")
55
+ return LocationMetadata(
56
+ latitude=float(lat) if isinstance(lat, (int, float)) else None,
57
+ longitude=float(lon) if isinstance(lon, (int, float)) else None,
58
+ name=place.get("name") or "",
59
+ admin=place.get("admin") or "",
60
+ country=place.get("country") or "",
61
+ country_code=place.get("country_code") or "",
62
+ timezone=place.get("tz_identifier") or "",
63
+ )
64
+
65
+
66
+ def _apply_place_metadata(
67
+ metadata: LocationMetadata, place: dict[str, Any] | None
68
+ ) -> None:
69
+ """Overlay geocoding place fields onto API metadata."""
70
+ if not place:
71
+ return
72
+ metadata.name = place.get("name") or metadata.name
73
+ metadata.admin = place.get("admin") or metadata.admin
74
+ metadata.country = place.get("country") or metadata.country
75
+ metadata.country_code = place.get("country_code") or metadata.country_code
76
+ if not metadata.timezone:
77
+ metadata.timezone = place.get("tz_identifier") or metadata.timezone
78
+
79
+
80
+ def _merge_air_quality_fields(
81
+ hourly_data: dict[str, Any], air_quality: dict[str, Any] | None
82
+ ) -> dict[str, Any]:
83
+ """Attach air-quality lists to hourly weather data."""
84
+ merged = dict(hourly_data)
85
+ if not air_quality:
86
+ return merged
87
+ aq_hourly = air_quality.get("hourly")
88
+ if not isinstance(aq_hourly, dict):
89
+ return merged
90
+
91
+ weather_times = merged.get("time", [])
92
+ weather_count = len(weather_times) if isinstance(weather_times, list) else 0
93
+ for field in AIR_QUALITY_FIELDS:
94
+ values = aq_hourly.get(field)
95
+ if not isinstance(values, list):
96
+ continue
97
+ if len(values) < weather_count:
98
+ values = values + [None] * (weather_count - len(values))
99
+ elif len(values) > weather_count:
100
+ values = values[:weather_count]
101
+ merged[field] = values
102
+ return merged
103
+
104
+
105
+ async def _build_forecast_bundle(
106
+ weather_data: dict[str, Any],
107
+ *,
108
+ country_code: str = "",
109
+ place: dict[str, Any] | None = None,
110
+ ) -> ForecastBundle:
111
+ """Build typed forecast models from raw API weather data."""
112
+ data = deepcopy(weather_data)
113
+ metadata = OpenMeteoForecast.extract_metadata(data)
114
+ _apply_place_metadata(metadata, place)
115
+
116
+ units_current = OpenMeteoForecast.extract_units(data, key="current")
117
+ units_hourly = OpenMeteoForecast.extract_units(data, key="hourly")
118
+ units_daily = OpenMeteoForecast.extract_units(data, key="daily")
119
+
120
+ hourly_data = data.get("hourly") if isinstance(data.get("hourly"), dict) else {}
121
+ times = hourly_data.get("time", []) if isinstance(hourly_data, dict) else []
122
+ start = times[0] if times else None
123
+ end = times[-1] if times else None
124
+
125
+ air_quality = None
126
+ if (
127
+ start
128
+ and end
129
+ and metadata.latitude is not None
130
+ and metadata.longitude is not None
131
+ ):
132
+ air_quality = await fetch_air_quality(
133
+ metadata.latitude,
134
+ metadata.longitude,
135
+ start,
136
+ end,
137
+ country_code or metadata.country_code,
138
+ )
139
+
140
+ merged_hourly = _merge_air_quality_fields(hourly_data, air_quality)
141
+ data["hourly"] = merged_hourly
142
+
143
+ current = CurrentOpenMeteoForecast(metadata, units_current, data.get("current", {}))
144
+ hourly = HourlyOpenMeteoForecast(metadata, units_hourly, merged_hourly)
145
+ daily = DailyOpenMeteoForecast(metadata, units_daily, data.get("daily", {}))
146
+
147
+ return ForecastBundle(
148
+ metadata=metadata,
149
+ current=current,
150
+ hourly=hourly,
151
+ daily=daily,
152
+ raw=data,
153
+ )
154
+
155
+
156
+ async def geocode(
157
+ query: str,
158
+ *,
159
+ count: int = 5,
160
+ language: str = "en",
161
+ ) -> list[LocationMetadata]:
162
+ """Search for places and return normalized location metadata entries."""
163
+ places = await search_places(query, count=count, language=language)
164
+ return [_location_metadata_from_place(place) for place in places]
165
+
166
+
167
+ def geocode_sync(
168
+ query: str,
169
+ *,
170
+ count: int = 5,
171
+ language: str = "en",
172
+ ) -> list[LocationMetadata]:
173
+ """Synchronous wrapper for :func:`geocode`."""
174
+ return _run_sync(geocode(query, count=count, language=language))
175
+
176
+
177
+ async def forecast_by_coordinates(
178
+ *,
179
+ lat: float,
180
+ lon: float,
181
+ temperature_unit: str = "celsius",
182
+ wind_speed_unit: str = "kmh",
183
+ precipitation_unit: str = "mm",
184
+ country_code: str = "",
185
+ ) -> ForecastBundle:
186
+ """Fetch forecasts for explicit coordinates."""
187
+ weather_data = await fetch_weather(
188
+ lat=lat,
189
+ lon=lon,
190
+ temperature_unit=temperature_unit,
191
+ wind_speed_unit=wind_speed_unit,
192
+ precipitation_unit=precipitation_unit,
193
+ )
194
+ return await _build_forecast_bundle(weather_data, country_code=country_code)
195
+
196
+
197
+ def forecast_by_coordinates_sync(
198
+ *,
199
+ lat: float,
200
+ lon: float,
201
+ temperature_unit: str = "celsius",
202
+ wind_speed_unit: str = "kmh",
203
+ precipitation_unit: str = "mm",
204
+ country_code: str = "",
205
+ ) -> ForecastBundle:
206
+ """Synchronous wrapper for :func:`forecast_by_coordinates`."""
207
+ return _run_sync(
208
+ forecast_by_coordinates(
209
+ lat=lat,
210
+ lon=lon,
211
+ temperature_unit=temperature_unit,
212
+ wind_speed_unit=wind_speed_unit,
213
+ precipitation_unit=precipitation_unit,
214
+ country_code=country_code,
215
+ )
216
+ )
217
+
218
+
219
+ async def forecast_by_place(
220
+ query: str,
221
+ *,
222
+ language: str = "en",
223
+ temperature_unit: str = "celsius",
224
+ wind_speed_unit: str = "kmh",
225
+ precipitation_unit: str = "mm",
226
+ ) -> ForecastBundle:
227
+ """Geocode a place query and fetch forecasts for the best match."""
228
+ matches = await search_places(query, count=1, language=language)
229
+ if not matches:
230
+ raise LocationNotFoundError(f"No location found for query: {query!r}")
231
+
232
+ place = matches[0]
233
+ lat = place.get("latitude")
234
+ lon = place.get("longitude")
235
+ if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
236
+ raise WevvaAPIError(f"Geocoding result for {query!r} is missing coordinates.")
237
+
238
+ weather_data = await fetch_weather(
239
+ lat=float(lat),
240
+ lon=float(lon),
241
+ temperature_unit=temperature_unit,
242
+ wind_speed_unit=wind_speed_unit,
243
+ precipitation_unit=precipitation_unit,
244
+ )
245
+ return await _build_forecast_bundle(
246
+ weather_data,
247
+ country_code=place.get("country_code") or "",
248
+ place=place,
249
+ )
250
+
251
+
252
+ def forecast_by_place_sync(
253
+ query: str,
254
+ *,
255
+ language: str = "en",
256
+ temperature_unit: str = "celsius",
257
+ wind_speed_unit: str = "kmh",
258
+ precipitation_unit: str = "mm",
259
+ ) -> ForecastBundle:
260
+ """Synchronous wrapper for :func:`forecast_by_place`."""
261
+ return _run_sync(
262
+ forecast_by_place(
263
+ query,
264
+ language=language,
265
+ temperature_unit=temperature_unit,
266
+ wind_speed_unit=wind_speed_unit,
267
+ precipitation_unit=precipitation_unit,
268
+ )
269
+ )
270
+
271
+
272
+ __all__ = [
273
+ "LocationNotFoundError",
274
+ "WevvaAPIError",
275
+ "forecast_by_coordinates",
276
+ "forecast_by_coordinates_sync",
277
+ "forecast_by_place",
278
+ "forecast_by_place_sync",
279
+ "geocode",
280
+ "geocode_sync",
281
+ ]
wevva/app.py ADDED
@@ -0,0 +1,186 @@
1
+ """Textual TUI for displaying weather forecasts and warnings (mini version).
2
+
3
+ Public-facing code should be easy to scan; this file adds concise
4
+ comments to highlight major blocks, actions, and message handlers.
5
+ Behavior remains unchanged.
6
+ """
7
+
8
+ from typing import ClassVar
9
+
10
+ from textual.app import App
11
+
12
+ from wevva.controller import WeatherController # central async orchestrator
13
+ from wevva.location_metadata import LocationMetadata
14
+ from wevva.messages import PlaceSelected, WeatherFetchFailed, WeatherUpdated
15
+ from wevva.screens.help import HelpScreen
16
+ from wevva.screens.search_screen import SearchScreen
17
+ from wevva.screens.settings_screen import SettingsScreen
18
+ from wevva.screens.weather_screen import WeatherScreen
19
+
20
+
21
+ class Wevva(App, inherit_bindings=False):
22
+ """Minimal textual weather app showing current, next 24h, daily and warnings.
23
+
24
+ - Message-first architecture: widgets react to `WeatherUpdated`.
25
+ - Compose once, then update in place for new data.
26
+ - Keep IDs stable to maintain `wevva.tcss` compatibility.
27
+ """
28
+
29
+ CSS_PATH = "wevva.tcss" # single theme stylesheet
30
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
31
+ ("q", "quit", "Quit"), # exit the app
32
+ ("s", "search", "Search"), # open place search screen
33
+ ("r", "refresh", "Refresh"), # fetch latest forecast
34
+ ("h", "help", "Help"), # show quick help
35
+ ("u", "settings", "Units"), # open settings
36
+ ]
37
+
38
+ def __init__(
39
+ self,
40
+ initial_location: LocationMetadata | None = None,
41
+ emoji_enabled: bool = True,
42
+ theme_name: str | None = None,
43
+ temperature_unit: str = "celsius",
44
+ wind_speed_unit: str = "kmh",
45
+ precipitation_unit: str = "mm",
46
+ **kwargs,
47
+ ):
48
+ """Initialize application (no postcode required; starts with place search).
49
+
50
+ Sets up controller, location state, and a refresh guard.
51
+ """
52
+ super().__init__(**kwargs)
53
+ self.controller = WeatherController(
54
+ temperature_unit=temperature_unit,
55
+ wind_speed_unit=wind_speed_unit,
56
+ precipitation_unit=precipitation_unit,
57
+ )
58
+ self.sub_title = (
59
+ "Weather data from Open-Meteo" # static subtitle for all screens
60
+ )
61
+ self.forecast_metadata = None # LocationMetadata after first fetch
62
+ # unified location context (holds geocoded place + last forecast metadata)
63
+ self.location = initial_location or LocationMetadata() # set from CLI or search
64
+ # Track whether the app started with a CLI-provided location and any successful fetch yet
65
+ self.started_with_cli_location = initial_location is not None
66
+ self._has_successful_fetch = False
67
+ # guard to prevent overlapping refreshes
68
+ self._refresh_in_flight = False # debounce concurrent refreshes
69
+ # Emoji rendering toggle (widgets can read via self.app.emoji_enabled)
70
+ self.emoji_enabled = bool(emoji_enabled)
71
+ # Store unit preferences for widgets
72
+ self.temperature_unit = temperature_unit
73
+ self.wind_speed_unit = wind_speed_unit
74
+ self.precipitation_unit = precipitation_unit
75
+ # Initialize main weather screen once
76
+ self.weather_screen = WeatherScreen()
77
+ # Theme selection from CLI (validated by Textual during assignment)
78
+ if theme_name is not None:
79
+ self.theme = theme_name
80
+
81
+ async def on_mount(self):
82
+ """Start with search screen, or if location provided via CLI, fetch weather directly."""
83
+ if self.location.latitude is not None and self.location.longitude is not None:
84
+ self.push_screen(self.weather_screen)
85
+ await self.action_refresh()
86
+ else:
87
+ self.push_screen(SearchScreen())
88
+
89
+ # ------------------------------------------------------------
90
+ # Actions / key bindings
91
+ # ------------------------------------------------------------
92
+ async def action_refresh(self) -> None:
93
+ """Fetch via controller and broadcast `WeatherUpdated`.
94
+
95
+ Uses an in-flight guard to prevent overlapping requests.
96
+ """
97
+ if self._refresh_in_flight:
98
+ return
99
+ self._refresh_in_flight = True
100
+ try:
101
+ event = await self.controller.fetch(
102
+ lat=self.location.latitude,
103
+ lon=self.location.longitude,
104
+ country_code=self.location.country_code,
105
+ )
106
+ # Forward fresh data to the weather screen
107
+ self.weather_screen.post_message(event)
108
+ except Exception as e:
109
+ # Forward error to the weather screen to surface it
110
+ self.weather_screen.post_message(WeatherFetchFailed(e))
111
+ finally:
112
+ self._refresh_in_flight = False
113
+
114
+ def action_search(self): # textual binding: 's'
115
+ """Open place search screen (fresh instance)."""
116
+ self.push_screen(SearchScreen())
117
+
118
+ def action_help(self): # textual binding: 'h'
119
+ """Open help screen."""
120
+ self.push_screen(HelpScreen())
121
+
122
+ def action_settings(self) -> None:
123
+ """Open settings screen and handle result via callback."""
124
+ self.push_screen(
125
+ SettingsScreen(
126
+ temperature_unit=self.temperature_unit,
127
+ wind_speed_unit=self.wind_speed_unit,
128
+ precipitation_unit=self.precipitation_unit,
129
+ ),
130
+ callback=self._on_settings_result,
131
+ )
132
+
133
+ async def _on_settings_result(self, result: dict | None) -> None:
134
+ """Handle settings screen dismiss with optional result."""
135
+ # If user clicked Apply (result is dict), update units and refresh
136
+ if result:
137
+ self.temperature_unit = result["temperature_unit"]
138
+ self.wind_speed_unit = result["wind_speed_unit"]
139
+ self.precipitation_unit = result["precipitation_unit"]
140
+
141
+ # Update controller with new units
142
+ self.controller = WeatherController(
143
+ temperature_unit=self.temperature_unit,
144
+ wind_speed_unit=self.wind_speed_unit,
145
+ precipitation_unit=self.precipitation_unit,
146
+ )
147
+
148
+ # Refresh weather with new units
149
+ if (
150
+ self.location.latitude is not None
151
+ and self.location.longitude is not None
152
+ ):
153
+ await self.action_refresh()
154
+
155
+ # ---------------- Messages ----------------
156
+ async def on_place_selected(self, message: PlaceSelected) -> None:
157
+ """Handle place selection → fetch → show main content.
158
+
159
+ Adopts selected location first so widgets see correct context on WeatherUpdated.
160
+ """
161
+ self.location = message.location
162
+ self.push_screen(self.weather_screen)
163
+ await self.action_refresh()
164
+
165
+ async def on_weather_updated(self, event: WeatherUpdated) -> None:
166
+ """Cache forecast metadata and merge API data into location."""
167
+ self.forecast_metadata = event.metadata
168
+ # Merge API-provided fields into app location
169
+ if event.metadata.elevation is not None:
170
+ self.location.elevation = event.metadata.elevation
171
+ if event.metadata.timezone_abbreviation:
172
+ self.location.timezone_abbreviation = event.metadata.timezone_abbreviation
173
+ self._has_successful_fetch = True
174
+
175
+ async def on_weather_fetch_failed(self, event: WeatherFetchFailed) -> None:
176
+ """Show error notification; return to search if CLI location failed on first fetch."""
177
+ self.notify(
178
+ f"Refresh failed: {type(event.error).__name__}: {event.error}",
179
+ title="Weather Fetch Failed",
180
+ severity="error",
181
+ timeout=5.0,
182
+ )
183
+
184
+ # If CLI location failed on first fetch, return to search for recovery
185
+ if self.started_with_cli_location and not self._has_successful_fetch:
186
+ self.push_screen(SearchScreen())