-
Notifications
You must be signed in to change notification settings - Fork 1
Add weather extension with API integration #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chrisdedman
wants to merge
17
commits into
main
Choose a base branch
from
ext-weather
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
ee19fd3
Add weather extension with API integration and configuration support
chrisdedman 3c84ec6
ran blacks locally to format the project
chrisdedman a8b53a7
remove weather info from readme
chrisdedman 7dfca9a
refactor user feedback
chrisdedman 3031d88
Merge branch 'main' into ext-weather
e93be79
Refactor weather command to remove context dependency for API key ret…
chrisdedman dd9ab76
separated into module the weather extension
chrisdedman 4b3a9cf
fix test after weather extension refator
chrisdedman b2da9a6
Refactor weather extension: modularize weather service and helper fun…
chrisdedman c1a7dcd
added requests deps for devs
chrisdedman 6e2076a
Refactor WeatherPayload and TimezonePayload for improved structure an…
chrisdedman 7bbc653
adde types-requests to dev deps
chrisdedman 75f49b9
Merge branch 'main' into ext-weather
chrisdedman 2e24f47
Merge branch 'main' into ext-weather
chrisdedman 2197201
Refactor weather service and helper to move classes
chrisdedman 5624f4c
Add error handling for missing API key in weather extension
chrisdedman 6f4f0a0
added api_key chec kback to avoid mypy errors
chrisdedman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import logging | ||
| import requests | ||
|
|
||
| from collections.abc import Mapping | ||
| from enum import Enum | ||
| from typing import cast | ||
| from typing import NotRequired, TypedDict | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| OPENWEATHER_URL = "https://api.openweathermap.org/data/2.5/weather" | ||
|
|
||
|
|
||
| class WeatherCondition(TypedDict): | ||
| """OpenWeather condition fields used when formatting a forecast.""" | ||
|
|
||
| description: str | ||
| icon: NotRequired[str] # TODO: figure out how to render icons | ||
|
|
||
|
|
||
| class MainPayload(TypedDict): | ||
| """OpenWeather temperature and atmosphere fields used by the formatter.""" | ||
|
|
||
| temp: float | ||
| feels_like: float | ||
| temp_min: float | ||
| temp_max: float | ||
| pressure: int | ||
| humidity: int | ||
|
|
||
|
|
||
| class WeatherPayload(TypedDict): | ||
| """TypedDict for the weather fields used when formatting a forecast.""" | ||
|
|
||
| weather: list[WeatherCondition] | ||
| main: MainPayload | ||
| visibility: int | None | ||
| timestamp: int | ||
|
|
||
|
|
||
| class WeatherError(Enum): | ||
| """List of possible errors when fetching weather data.""" | ||
|
|
||
| NOT_FOUND = "not_found" | ||
| UNAVAILABLE = "unavailable" | ||
|
|
||
|
|
||
| def fetch_weather( | ||
| api_key: str, | ||
| city: str, | ||
| ) -> WeatherPayload | WeatherError: | ||
| """Retrieve weather information for the specified city.""" | ||
|
|
||
| try: | ||
| response = requests.get( | ||
| OPENWEATHER_URL, | ||
| params={"appid": api_key, "q": city}, | ||
| timeout=10, | ||
| ) | ||
|
|
||
| if response.status_code == 404: | ||
| return WeatherError.NOT_FOUND | ||
|
|
||
| response.raise_for_status() | ||
| return _normalize_weather_payload(response.json()) | ||
| except requests.HTTPError as error: | ||
| logger.warning("Weather API HTTP error for %r: %s", city, error) | ||
| except (requests.ConnectionError, requests.Timeout) as error: | ||
| logger.warning("Weather API connection error for %r: %s", city, error) | ||
| except (KeyError, TypeError, ValueError) as error: | ||
| logger.warning("Weather API payload error for %r: %s", city, error) | ||
|
|
||
| return WeatherError.UNAVAILABLE | ||
|
|
||
|
|
||
| def _normalize_weather_payload(payload: object) -> WeatherPayload: | ||
| raw_payload = cast(Mapping[str, object], payload) | ||
| timezone = int(cast(int | str, raw_payload.get("timezone", 0))) | ||
| raw_visibility = raw_payload.get("visibility") | ||
|
|
||
| return { | ||
| "weather": cast(list[WeatherCondition], raw_payload["weather"]), | ||
| "main": cast(MainPayload, raw_payload["main"]), | ||
| "visibility": ( | ||
| int(cast(int | str, raw_visibility)) if raw_visibility is not None else None | ||
| ), | ||
| "timestamp": int(cast(int | str, raw_payload["dt"])) + timezone, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| from matrix import Context, Extension | ||
| from matrix.errors import CheckError | ||
| from .openweather_service import WeatherError, fetch_weather | ||
| from .weather_helper import format_weather | ||
|
|
||
| extension = Extension("weather") | ||
|
|
||
|
|
||
| def _normalize_city_name(city_parts: tuple[str, ...]) -> str: | ||
| return " ".join(city_parts).title().strip() | ||
|
|
||
|
|
||
| def _get_api_key() -> str | None: | ||
| return extension.bot.config.get("api_key", section="extensions.weather") | ||
|
|
||
|
|
||
| @extension.check | ||
| async def has_api_key(_ctx: Context) -> bool: | ||
| return _get_api_key() is not None | ||
|
|
||
|
|
||
| @extension.error(CheckError) | ||
| async def missing_api_key(ctx: Context, error: CheckError) -> None: | ||
| if isinstance(error, CheckError): | ||
| await ctx.reply( | ||
| "Weather extension is not configured with an API key. " | ||
| "Please set the api_key in the configuration to use this command." | ||
| ) | ||
|
|
||
|
|
||
| @extension.command( | ||
| "weather", description="Show current weather information for a city." | ||
| ) | ||
| async def weather(ctx: Context, *city: str) -> None: | ||
| city_name = _normalize_city_name(city) | ||
|
|
||
| api_key: str | None = _get_api_key() | ||
| if not api_key: | ||
| await ctx.reply("Weather is not configured.") | ||
| return | ||
|
|
||
| result = fetch_weather(api_key, city_name) | ||
| match result: | ||
| case WeatherError.NOT_FOUND: | ||
| await ctx.reply(f"Could not find weather data for {city_name}.") | ||
| case WeatherError.UNAVAILABLE: | ||
| await ctx.reply("Weather service is temporarily unavailable.") | ||
| case _: | ||
| await ctx.reply(format_weather(city_name, result)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| from datetime import UTC, datetime | ||
| from .openweather_service import WeatherPayload | ||
|
|
||
|
|
||
| def format_weather(city: str, data: WeatherPayload) -> str: | ||
| current_time = _city_time(data["timestamp"]).strftime("%Y-%m-%d %H:%M") | ||
| weather = data["weather"][0] | ||
| main = data["main"] | ||
|
|
||
| visibility_m = data["visibility"] | ||
| visibility_ft = ( | ||
| f"{round(visibility_m * 3.280839895):,}ft" | ||
| if visibility_m is not None | ||
| else "n/a" | ||
| ) | ||
|
|
||
| description = weather["description"].title() | ||
|
|
||
| temperature = _format_temperature(main["temp"]) | ||
| feels_like = _format_temperature(main["feels_like"]) | ||
| temp_min = _format_temperature(main["temp_min"]) | ||
| temp_max = _format_temperature(main["temp_max"]) | ||
|
|
||
| humidity = main["humidity"] | ||
| pressure = main["pressure"] | ||
| visibility = _format_visibility(visibility_m, visibility_ft) | ||
|
|
||
| return ( | ||
| f"### Weather for {city}\n" | ||
| f"<pre>" | ||
|
PenguinBoi12 marked this conversation as resolved.
|
||
| f"{'Local time:':<14} {current_time}\n" | ||
| f"{'Description:':<14} {description}\n" | ||
| f"{'Temperature:':<14} {temperature}\n" | ||
| f"{'- Min:':<14} {temp_min}\n" | ||
| f"{'- Max:':<14} {temp_max}\n" | ||
| f"{'Feels like:':<14} {feels_like}\n" | ||
| f"{'Humidity:':<14} {humidity}%\n" | ||
| f"{'Pressure:':<14} {pressure:,} hPa\n" | ||
| f"{'Visibility:':<14} {visibility}\n" | ||
| f"</pre>" | ||
| ) | ||
|
|
||
|
|
||
| def _city_time(timestamp: int) -> datetime: | ||
| """Get the local time for the city from a normalized timestamp.""" | ||
|
|
||
| return datetime.fromtimestamp(timestamp, UTC) | ||
|
|
||
|
|
||
| def _format_visibility(visibility_m: int | None, visibility_ft: str) -> str: | ||
| if visibility_m is None: | ||
| return "n/a" | ||
|
|
||
| return f"{visibility_m:,}m | {visibility_ft}" | ||
|
|
||
|
|
||
| def _format_temperature(kelvin: float) -> str: | ||
| fahrenheit = _kelvin_to_fahrenheit(kelvin) | ||
| celsius = _kelvin_to_celsius(kelvin) | ||
| return f"{fahrenheit:.2f}°F | {celsius:.2f}°C" | ||
|
|
||
|
|
||
| def _kelvin_to_celsius(kelvin: float) -> float: | ||
| return kelvin - 273.15 | ||
|
|
||
|
|
||
| def _kelvin_to_fahrenheit(kelvin: float) -> float: | ||
| return kelvin * 1.8 - 459.67 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,3 +14,5 @@ bot: | |
| extensions: | ||
| welcome: | ||
| room: "${ADA_MAIN_ROOM|}" | ||
| weather: | ||
| api_key: "${ADA_OPENWEATHER_API_KEY|}" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| from datetime import UTC, datetime | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| from bot.extensions.weather.openweather_service import WeatherError, fetch_weather | ||
| from bot.extensions.weather.weather_helper import ( | ||
| _city_time, | ||
| _format_temperature, | ||
| _format_visibility, | ||
| format_weather, | ||
| _kelvin_to_celsius, | ||
| _kelvin_to_fahrenheit, | ||
| ) | ||
|
|
||
| from bot.extensions.weather.weather_extension import _normalize_city_name | ||
|
|
||
|
|
||
| def test_kelvin_to_celsius() -> None: | ||
| assert round(_kelvin_to_celsius(273.15), 2) == 0 | ||
|
|
||
|
|
||
| def test_kelvin_to_fahrenheit() -> None: | ||
| assert round(_kelvin_to_fahrenheit(273.15), 2) == 32 | ||
|
|
||
|
|
||
| def test_format_temperature() -> None: | ||
| assert _format_temperature(273.15) == "32.00°F | 0.00°C" | ||
|
|
||
|
|
||
| def test_format_visibility_handles_missing_value() -> None: | ||
| assert _format_visibility(None, "n/a") == "n/a" | ||
|
|
||
|
|
||
| def test_normalize_city_name_preserves_multiword_city() -> None: | ||
| assert _normalize_city_name(("los", "angeles")) == "Los Angeles" | ||
|
|
||
|
|
||
| def test_city_time_uses_timestamp() -> None: | ||
| assert _city_time(0) == datetime.fromtimestamp(0, UTC) | ||
|
|
||
|
|
||
| def test_fetch_weather_normalizes_openweather_payload() -> None: | ||
| fake_response = MagicMock() | ||
| fake_response.status_code = 200 | ||
| fake_response.json.return_value = { | ||
| "dt": 1000, | ||
| "timezone": 3600, | ||
| "visibility": 10000, | ||
| "weather": [{"description": "clear sky", "icon": "01d"}], | ||
| "main": { | ||
| "temp": 293.15, | ||
| "feels_like": 294.15, | ||
| "temp_min": 292.15, | ||
| "temp_max": 295.15, | ||
| "humidity": 52, | ||
| "pressure": 1014, | ||
| }, | ||
| } | ||
|
|
||
| with patch( | ||
| "bot.extensions.weather.openweather_service.requests.get", | ||
| return_value=fake_response, | ||
| ): | ||
| result = fetch_weather("api-key", "Paris") | ||
|
|
||
| assert not isinstance(result, WeatherError) | ||
| assert result["timestamp"] == 4600 | ||
| assert result["visibility"] == 10000 | ||
|
|
||
|
|
||
| def test_format_weather() -> None: | ||
| message = format_weather( | ||
| "Paris", | ||
| { | ||
| "timestamp": 0, | ||
| "visibility": 10000, | ||
| "weather": [{"description": "clear sky", "icon": "01d"}], | ||
| "main": { | ||
| "temp": 293.15, | ||
| "feels_like": 294.15, | ||
| "temp_min": 292.15, | ||
| "temp_max": 295.15, | ||
| "humidity": 52, | ||
| "pressure": 1014, | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| lines = message.splitlines() | ||
|
|
||
| assert lines[0] == "### Weather for Paris" | ||
| assert lines[1] == "<pre>Local time: 1970-01-01 00:00" | ||
| assert lines[2] == "Description: Clear Sky" | ||
| assert lines[3] == "Temperature: 68.00°F | 20.00°C" | ||
| assert lines[4] == "- Min: 66.20°F | 19.00°C" | ||
| assert lines[5] == "- Max: 71.60°F | 22.00°C" | ||
| assert lines[6] == "Feels like: 69.80°F | 21.00°C" | ||
| assert lines[7] == "Humidity: 52%" | ||
| assert lines[8] == "Pressure: 1,014 hPa" | ||
| assert lines[9] == "Visibility: 10,000m | 32,808ft" | ||
| assert lines[10] == "</pre>" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.