From 3e80ccb0df9e54e65e5e4324f706561795ab4a26 Mon Sep 17 00:00:00 2001 From: shmyga Date: Sun, 25 Aug 2024 23:28:49 +0300 Subject: [PATCH] feat(weather): add openweather api --- gallery/easel/__init__.py | 26 +- gallery/easel/core.py | 15 + gallery/easel/route/api/__init__.py | 8 + gallery/easel/route/api/weather.py | 16 +- gallery/easel/route/view/__init__.py | 9 + gallery/easel/route/view/schedule/__init__.py | 16 +- gallery/easel/route/view/weather/__init__.py | 26 +- gallery/easel/route/view/weather/filters.py | 17 +- gallery/main.py | 13 +- gallery/painting/gismeteo/parser.py | 20 +- gallery/painting/openweather/__init__.py | 0 gallery/painting/openweather/api.py | 70 + gallery/painting/openweather/mock/__init__.py | 5 + .../openweather/mock/data/forecast.json | 1139 +++++++++++++++++ gallery/painting/openweather/openweather.py | 83 ++ gallery/painting/openweather/parser.py | 52 + gallery/sketch/api.py | 6 + gallery/sketch/bundle.py | 30 + gallery/sketch/cached.py | 6 +- gallery/sketch/catalog.py | 3 + gallery/sketch/mock.py | 7 +- gallery/sketch/weather/catalog.py | 14 +- gallery/sketch/weather/model.py | 57 +- gallery/sketch/weather/util.py | 46 +- tests/test_openweather_api.py | 27 + 25 files changed, 1636 insertions(+), 75 deletions(-) create mode 100644 gallery/easel/core.py create mode 100644 gallery/painting/openweather/__init__.py create mode 100644 gallery/painting/openweather/api.py create mode 100644 gallery/painting/openweather/mock/__init__.py create mode 100644 gallery/painting/openweather/mock/data/forecast.json create mode 100644 gallery/painting/openweather/openweather.py create mode 100644 gallery/painting/openweather/parser.py create mode 100644 gallery/sketch/bundle.py create mode 100644 tests/test_openweather_api.py diff --git a/gallery/easel/__init__.py b/gallery/easel/__init__.py index 6782fcf..f6ab94d 100644 --- a/gallery/easel/__init__.py +++ b/gallery/easel/__init__.py @@ -2,32 +2,22 @@ import locale as _locale from fastapi import FastAPI -from gallery.sketch.schedule.api import ScheduleApi -from gallery.sketch.weather.api import WeatherApi +from gallery.sketch.bundle import ApiBundle -from .route import doc -from .route.api import schedule as schedule_api_route -from .route.api import weather as weather_api_route -from .route.view import common as common_view_route -from .route.view import schedule as schedule_view_route -from .route.view import weather as weather_view_route +from .route import api, doc, view + +DEFAULT_LOCALE = "ru_RU.UTF-8" -def build_app( - weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8" -) -> FastAPI: +def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI: _locale.setlocale(_locale.LC_TIME, locale) app = FastAPI( title="Gallery", docs_url=None, redoc_url=None, ) - app.state.weather_api = weather_api - app.state.schedule_api = schedule_api + app.state.api = api_bundle doc.mount(app) - weather_api_route.mount(app) - schedule_api_route.mount(app) - common_view_route.mount(app) - weather_view_route.mount(app) - schedule_view_route.mount(app) + api.mount(app) + view.mount(app) return app diff --git a/gallery/easel/core.py b/gallery/easel/core.py new file mode 100644 index 0000000..85f05c9 --- /dev/null +++ b/gallery/easel/core.py @@ -0,0 +1,15 @@ +from fastapi import Request + +from gallery.sketch.bundle import ApiBundle + + +class State: + api: ApiBundle + + +class App: + state: State + + +class AppRequest(Request): + app: App diff --git a/gallery/easel/route/api/__init__.py b/gallery/easel/route/api/__init__.py index e69de29..fcfa3fe 100644 --- a/gallery/easel/route/api/__init__.py +++ b/gallery/easel/route/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +from . import schedule, weather + + +def mount(app: FastAPI): + weather.mount(app) + schedule.mount(app) diff --git a/gallery/easel/route/api/weather.py b/gallery/easel/route/api/weather.py index 583af94..c0148ca 100644 --- a/gallery/easel/route/api/weather.py +++ b/gallery/easel/route/api/weather.py @@ -1,27 +1,27 @@ import datetime -from fastapi import FastAPI, Request +from fastapi import FastAPI -from gallery.sketch.weather.api import WeatherApi +from gallery.easel.core import AppRequest from gallery.sketch.weather.model import WeatherResponse def mount(app: FastAPI): @app.get("/api/weather/locations") - async def get_api_weather_locations(request: Request) -> list[str]: - weather_api: WeatherApi = request.app.state.weather_api + async def get_api_weather_locations(request: AppRequest) -> list[str]: + weather_api = request.app.state.api.weather return await weather_api.get_locations() @app.get("/api/weather/{location}/day/{date}") async def get_api_weather_day( - request: Request, location: str, date: datetime.date + request: AppRequest, location: str, date: datetime.date ) -> WeatherResponse: - weather_api: WeatherApi = request.app.state.weather_api + weather_api = request.app.state.api.weather return await weather_api.get_day(location, date) @app.get("/api/weather/{location}/days/{days}") async def get_api_weather_days( - request: Request, location: str, days: int + request: AppRequest, location: str, days: int ) -> WeatherResponse: - weather_api: WeatherApi = request.app.state.weather_api + weather_api = request.app.state.api.weather return await weather_api.get_days(location, days) diff --git a/gallery/easel/route/view/__init__.py b/gallery/easel/route/view/__init__.py index e69de29..b99b8d6 100644 --- a/gallery/easel/route/view/__init__.py +++ b/gallery/easel/route/view/__init__.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from . import common, schedule, weather + + +def mount(app: FastAPI): + common.mount(app) + weather.mount(app) + schedule.mount(app) diff --git a/gallery/easel/route/view/schedule/__init__.py b/gallery/easel/route/view/schedule/__init__.py index b208bb8..441b22e 100644 --- a/gallery/easel/route/view/schedule/__init__.py +++ b/gallery/easel/route/view/schedule/__init__.py @@ -1,12 +1,12 @@ import datetime from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import FastAPI from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from gallery.sketch.schedule.api import ScheduleApi +from gallery.easel.core import AppRequest from gallery.sketch.schedule.catalog import BUNDLE from gallery.version import __version__ @@ -21,8 +21,8 @@ def mount(app: FastAPI): templates.env.filters["timedelta_format"] = timedelta_format @app.get("/schedule", response_class=HTMLResponse) - async def get_schedule_list(request: Request): - schedule_api: ScheduleApi = request.app.state.schedule_api + async def get_schedule_list(request: AppRequest): + schedule_api = request.app.state.api.schedule channels = await schedule_api.get_channels() channels_data = BUNDLE.select_items(channels) return templates.TemplateResponse( @@ -35,9 +35,9 @@ def mount(app: FastAPI): ) @app.get("/schedule/tag/{tag}", response_class=HTMLResponse) - async def get_schedule_tag(request: Request, tag: str, live: bool = False): + async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False): tag_value = TagUtil.parse_tag(tag) - schedule_api: ScheduleApi = request.app.state.schedule_api + schedule_api = request.app.state.api.schedule channels = await schedule_api.get_channels() responses = [ await schedule_api.get_channel_schedule(channel, tag_value.date) @@ -62,9 +62,9 @@ def mount(app: FastAPI): return RedirectResponse(f"{channel}/tag/today") @app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse) - async def get_channel_tag(request: Request, channel: str, tag: str): + async def get_channel_tag(request: AppRequest, channel: str, tag: str): tag_value = TagUtil.parse_tag(tag) - schedule_api: ScheduleApi = request.app.state.schedule_api + schedule_api = request.app.state.api.schedule if tag_value.type == TagType.DAY: response = await schedule_api.get_channel_schedule(channel, tag_value.date) else: diff --git a/gallery/easel/route/view/weather/__init__.py b/gallery/easel/route/view/weather/__init__.py index 4be51d0..b03f300 100644 --- a/gallery/easel/route/view/weather/__init__.py +++ b/gallery/easel/route/view/weather/__init__.py @@ -1,12 +1,12 @@ import datetime from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import FastAPI from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from gallery.sketch.weather.api import WeatherApi +from gallery.easel.core import AppRequest from gallery.sketch.weather.catalog import BUNDLE from gallery.sketch.weather.mock import WEATHER_MOCK_DATA from gallery.sketch.weather.model import WeatherResponse @@ -23,7 +23,7 @@ def mount(app: FastAPI): templates.env.filters["wind_direction_icon"] = wind_direction_icon templates.env.filters["cloudness_icon"] = cloudness_icon - def build_weather_response(request: Request, response: WeatherResponse): + def build_weather_response(request: AppRequest, response: WeatherResponse): return templates.TemplateResponse( request=request, name="weather.html", @@ -36,8 +36,8 @@ def mount(app: FastAPI): ) @app.get("/weather", response_class=HTMLResponse) - async def get_weather_list(request: Request): - weather_api: WeatherApi = request.app.state.weather_api + async def get_weather_list(request: AppRequest): + weather_api = request.app.state.api.weather locations = await weather_api.get_locations() locations_data = BUNDLE.select_items(locations) return templates.TemplateResponse( @@ -54,31 +54,31 @@ def mount(app: FastAPI): return RedirectResponse(f"{location}/tag/today") @app.get("/weather/{location}/day/mock", response_class=HTMLResponse) - async def get_weather_day_mock(request: Request): + async def get_weather_day_mock(request: AppRequest): response = WEATHER_MOCK_DATA.get_response("day") return build_weather_response(request, response) @app.get("/weather/{location}/days/mock", response_class=HTMLResponse) - async def get_weather_days_mock(request: Request): + async def get_weather_days_mock(request: AppRequest): response = WEATHER_MOCK_DATA.get_response("days") return build_weather_response(request, response) @app.get("/weather/{location}/day/{date}", response_class=HTMLResponse) - async def get_weather_day(request: Request, location: str, date: datetime.date): - weather_api: WeatherApi = request.app.state.weather_api + async def get_weather_day(request: AppRequest, location: str, date: datetime.date): + weather_api = request.app.state.api.weather response = await weather_api.get_day(location, date) return build_weather_response(request, response) @app.get("/weather/{location}/days/{days}", response_class=HTMLResponse) - async def get_weather_days(request: Request, location: str, days: int): - weather_api: WeatherApi = request.app.state.weather_api + async def get_weather_days(request: AppRequest, location: str, days: int): + weather_api = request.app.state.api.weather response = await weather_api.get_days(location, days) return build_weather_response(request, response) @app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse) - async def get_weather_tag(request: Request, location: str, tag: str): + async def get_weather_tag(request: AppRequest, location: str, tag: str): tag_value = TagUtil.parse_tag(tag) - weather_api: WeatherApi = request.app.state.weather_api + weather_api = request.app.state.api.weather if tag_value.type == TagType.DAY: response = await weather_api.get_day(location, tag_value.date) elif tag_value.type == TagType.DAYS: diff --git a/gallery/easel/route/view/weather/filters.py b/gallery/easel/route/view/weather/filters.py index c24bda5..873414f 100644 --- a/gallery/easel/route/view/weather/filters.py +++ b/gallery/easel/route/view/weather/filters.py @@ -1,12 +1,19 @@ -from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection +from gallery.sketch.weather.model import ( + Cloudness, + Precipitation, + Sky, + WindDirection, + WindDirectionDeg, +) -def wind_direction_icon(wind_direction: WindDirection) -> str: +def wind_direction_icon(wind_direction_deg: float) -> str: + wind_direction = WindDirectionDeg(wind_direction_deg).direction return { WindDirection.N: "⬇️", - WindDirection.NO: "↙️", - WindDirection.O: "⬅️", - WindDirection.SO: "↖️", + WindDirection.NE: "↙️", + WindDirection.E: "⬅️", + WindDirection.SE: "↖️", WindDirection.S: "⬆️", WindDirection.SW: "↗️", WindDirection.W: "➡️", diff --git a/gallery/main.py b/gallery/main.py index b9b8fb7..96ba27b 100644 --- a/gallery/main.py +++ b/gallery/main.py @@ -6,12 +6,19 @@ import uvicorn from gallery.easel import build_app from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.matchtv.api import MatchTvApi +from gallery.painting.openweather.api import OpenWeatherApi +from gallery.sketch.bundle import ApiBundle from gallery.sketch.schedule.cached import CachedScheduleApi from gallery.sketch.weather.cached import CachedWeatherApi -weather_api = CachedWeatherApi(GismeteoApi()) -schedule_api = CachedScheduleApi(MatchTvApi()) -app = build_app(weather_api, schedule_api) +api = ApiBundle( + [ + CachedScheduleApi(MatchTvApi()), + CachedWeatherApi(GismeteoApi()), + CachedWeatherApi(OpenWeatherApi()), + ] +) +app = build_app(api) def run(): diff --git a/gallery/painting/gismeteo/parser.py b/gallery/painting/gismeteo/parser.py index 1e3f794..f2dbacc 100644 --- a/gallery/painting/gismeteo/parser.py +++ b/gallery/painting/gismeteo/parser.py @@ -5,7 +5,13 @@ from typing import Iterable import dateparser from bs4 import Tag -from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection +from gallery.sketch.weather.model import ( + Cloudness, + Precipitation, + Sky, + WindDirection, + WindDirectionDeg, +) from .core import BaseWidgetParser, RowParser @@ -126,21 +132,23 @@ class WindDirectionParser(RowParser[WindDirection]): WIND_DIRECTION_MAP: dict[str, WindDirection] = { "штиль": WindDirection.CALM, "с": WindDirection.N, - "св": WindDirection.NO, - "в": WindDirection.O, - "юв": WindDirection.SO, + "св": WindDirection.NE, + "в": WindDirection.E, + "юв": WindDirection.SE, "ю": WindDirection.S, "юз": WindDirection.SW, "з": WindDirection.W, "сз": WindDirection.NW, } - def parse_row(self, tag: Tag) -> Iterable[WindDirection]: + def parse_row(self, tag: Tag) -> Iterable[float]: for item in tag.select( ".widget-row[data-row=wind-direction] > .row-item > .direction" ): wind_direction_str = item.text.lower() - yield self.WIND_DIRECTION_MAP[wind_direction_str] + yield WindDirectionDeg.from_direction( + self.WIND_DIRECTION_MAP[wind_direction_str] + ).value class WindPrecipitationParser(RowParser[float]): diff --git a/gallery/painting/openweather/__init__.py b/gallery/painting/openweather/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/painting/openweather/api.py b/gallery/painting/openweather/api.py new file mode 100644 index 0000000..96f1981 --- /dev/null +++ b/gallery/painting/openweather/api.py @@ -0,0 +1,70 @@ +import datetime +import logging +from collections import defaultdict + +from aiocache import cached + +from gallery.sketch.weather.api import WeatherApi +from gallery.sketch.weather.catalog import BUNDLE, LocationId +from gallery.sketch.weather.model import WeatherResponse, WeatherValue +from gallery.sketch.weather.util import merge_weather_values +from gallery.util import TimeUnit + +from .openweather import Forecast, OpenWeather +from .parser import FORECAST_ITEM_PARSER + +logger = logging.getLogger("openweather") + + +class OpenWeatherApi(WeatherApi): + PROVIDER = "openweather" + SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") + + async def get_locations(self) -> list[str]: + return [ + LocationId.OREL, + LocationId.ZMIYEVKA, + ] + + @cached( + key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast", + alias="redis", + ttl=TimeUnit.DAY, + ) + async def _get_location_forecast(self, location_id: str) -> Forecast: + location = BUNDLE.get_item(location_id) + return await self.SOURCE.get_forecast(location.lat, location.lon) + + async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: + data: Forecast = await self._get_location_forecast(location_id) + values = [] + for item in data.list: + value = FORECAST_ITEM_PARSER.parse(item) + if value.date.date() == date: + values.append(value) + location = BUNDLE.get_item(location_id) + return WeatherResponse( + location=location.name, + date=date, + period="day", + values=values, + ) + + async def get_days(self, location_id: str, days: int) -> WeatherResponse: + data: Forecast = await self._get_location_forecast(location_id) + values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list) + for item in data.list: + value = FORECAST_ITEM_PARSER.parse(item) + item_date = value.date.replace(hour=0, minute=0) + values_by_date[item_date].append(value) + values = [ + merge_weather_values(date, values) + for date, values in values_by_date.items() + ] + location = BUNDLE.get_item(location_id) + return WeatherResponse( + location=location.name, + date=datetime.date.today(), + period="days", + values=list(sorted(values, key=lambda item: item.date)), + ) diff --git a/gallery/painting/openweather/mock/__init__.py b/gallery/painting/openweather/mock/__init__.py new file mode 100644 index 0000000..c4c7734 --- /dev/null +++ b/gallery/painting/openweather/mock/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +from gallery.sketch.mock import MockData + +OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data") diff --git a/gallery/painting/openweather/mock/data/forecast.json b/gallery/painting/openweather/mock/data/forecast.json new file mode 100644 index 0000000..e27efeb --- /dev/null +++ b/gallery/painting/openweather/mock/data/forecast.json @@ -0,0 +1,1139 @@ +{ + "cod": "200", + "message": 0, + "cnt": 40, + "list": [ + { + "dt": 1724317200, + "main": { + "temp": 26.55, + "feels_like": 26.55, + "temp_min": 26.55, + "temp_max": 30.48, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 988, + "humidity": 51, + "temp_kf": -3.93 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 69 }, + "wind": { "speed": 5.25, "deg": 134, "gust": 5.76 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-22 09:00:00" + }, + { + "dt": 1724328000, + "main": { + "temp": 30.21, + "feels_like": 29.59, + "temp_min": 30.21, + "temp_max": 33.02, + "pressure": 1010, + "sea_level": 1010, + "grnd_level": 987, + "humidity": 37, + "temp_kf": -2.81 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 81 }, + "wind": { "speed": 4.59, "deg": 140, "gust": 4.53 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-22 12:00:00" + }, + { + "dt": 1724338800, + "main": { + "temp": 30.37, + "feels_like": 29.27, + "temp_min": 30.37, + "temp_max": 30.37, + "pressure": 1009, + "sea_level": 1009, + "grnd_level": 986, + "humidity": 32, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": { "all": 90 }, + "wind": { "speed": 3.23, "deg": 188, "gust": 6.32 }, + "visibility": 10000, + "pop": 0.05, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-22 15:00:00" + }, + { + "dt": 1724349600, + "main": { + "temp": 20.99, + "feels_like": 20.89, + "temp_min": 20.99, + "temp_max": 20.99, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 988, + "humidity": 67, + "temp_kf": 0 + }, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "clouds": { "all": 93 }, + "wind": { "speed": 3.3, "deg": 92, "gust": 4.18 }, + "visibility": 10000, + "pop": 1, + "rain": { "3h": 1.65 }, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-22 18:00:00" + }, + { + "dt": 1724360400, + "main": { + "temp": 19.49, + "feels_like": 19.4, + "temp_min": 19.49, + "temp_max": 19.49, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 988, + "humidity": 73, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "clouds": { "all": 100 }, + "wind": { "speed": 4.14, "deg": 119, "gust": 9.89 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-22 21:00:00" + }, + { + "dt": 1724371200, + "main": { + "temp": 18.39, + "feels_like": 18.19, + "temp_min": 18.39, + "temp_max": 18.39, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 988, + "humidity": 73, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "clouds": { "all": 98 }, + "wind": { "speed": 1.69, "deg": 181, "gust": 2.13 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-23 00:00:00" + }, + { + "dt": 1724382000, + "main": { + "temp": 19, + "feels_like": 18.86, + "temp_min": 19, + "temp_max": 19, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 988, + "humidity": 73, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 83 }, + "wind": { "speed": 2.1, "deg": 135, "gust": 2.26 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-23 03:00:00" + }, + { + "dt": 1724392800, + "main": { + "temp": 24.97, + "feels_like": 24.83, + "temp_min": 24.97, + "temp_max": 24.97, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 989, + "humidity": 50, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 83 }, + "wind": { "speed": 1.93, "deg": 149, "gust": 2.63 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-23 06:00:00" + }, + { + "dt": 1724403600, + "main": { + "temp": 28.61, + "feels_like": 28.14, + "temp_min": 28.61, + "temp_max": 28.61, + "pressure": 1011, + "sea_level": 1011, + "grnd_level": 989, + "humidity": 39, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": { "all": 90 }, + "wind": { "speed": 2.44, "deg": 283, "gust": 2.54 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-23 09:00:00" + }, + { + "dt": 1724414400, + "main": { + "temp": 27.8, + "feels_like": 27.76, + "temp_min": 27.8, + "temp_max": 27.8, + "pressure": 1012, + "sea_level": 1012, + "grnd_level": 989, + "humidity": 44, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": { "all": 95 }, + "wind": { "speed": 6.43, "deg": 324, "gust": 4.93 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-23 12:00:00" + }, + { + "dt": 1724425200, + "main": { + "temp": 21.75, + "feels_like": 21.83, + "temp_min": 21.75, + "temp_max": 21.75, + "pressure": 1013, + "sea_level": 1013, + "grnd_level": 991, + "humidity": 71, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": { "all": 100 }, + "wind": { "speed": 5.87, "deg": 337, "gust": 8.46 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-23 15:00:00" + }, + { + "dt": 1724436000, + "main": { + "temp": 17.71, + "feels_like": 17.57, + "temp_min": 17.71, + "temp_max": 17.71, + "pressure": 1016, + "sea_level": 1016, + "grnd_level": 993, + "humidity": 78, + "temp_kf": 0 + }, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "clouds": { "all": 100 }, + "wind": { "speed": 4.42, "deg": 1, "gust": 9.8 }, + "visibility": 10000, + "pop": 0.2, + "rain": { "3h": 0.15 }, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-23 18:00:00" + }, + { + "dt": 1724446800, + "main": { + "temp": 15.92, + "feels_like": 15.55, + "temp_min": 15.92, + "temp_max": 15.92, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 994, + "humidity": 76, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "clouds": { "all": 77 }, + "wind": { "speed": 4.44, "deg": 344, "gust": 10.41 }, + "visibility": 10000, + "pop": 0.01, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-23 21:00:00" + }, + { + "dt": 1724457600, + "main": { + "temp": 14.43, + "feels_like": 13.97, + "temp_min": 14.43, + "temp_max": 14.43, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 994, + "humidity": 78, + "temp_kf": 0 + }, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03n" + } + ], + "clouds": { "all": 39 }, + "wind": { "speed": 3.4, "deg": 356, "gust": 8.85 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-24 00:00:00" + }, + { + "dt": 1724468400, + "main": { + "temp": 13.23, + "feels_like": 12.75, + "temp_min": 13.23, + "temp_max": 13.23, + "pressure": 1019, + "sea_level": 1019, + "grnd_level": 995, + "humidity": 82, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.68, "deg": 354, "gust": 8.66 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-24 03:00:00" + }, + { + "dt": 1724479200, + "main": { + "temp": 19.04, + "feels_like": 18.46, + "temp_min": 19.04, + "temp_max": 19.04, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 56, + "temp_kf": 0 + }, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": { "all": 35 }, + "wind": { "speed": 4.4, "deg": 2, "gust": 6.27 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-24 06:00:00" + }, + { + "dt": 1724490000, + "main": { + "temp": 23.57, + "feels_like": 23.03, + "temp_min": 23.57, + "temp_max": 23.57, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 40, + "temp_kf": 0 + }, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": { "all": 98 }, + "wind": { "speed": 4.28, "deg": 5, "gust": 4.72 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-24 09:00:00" + }, + { + "dt": 1724500800, + "main": { + "temp": 26.12, + "feels_like": 26.12, + "temp_min": 26.12, + "temp_max": 26.12, + "pressure": 1019, + "sea_level": 1019, + "grnd_level": 996, + "humidity": 35, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 70 }, + "wind": { "speed": 4.18, "deg": 3, "gust": 4.52 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-24 12:00:00" + }, + { + "dt": 1724511600, + "main": { + "temp": 25.15, + "feels_like": 24.77, + "temp_min": 25.15, + "temp_max": 25.15, + "pressure": 1019, + "sea_level": 1019, + "grnd_level": 997, + "humidity": 40, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": { "all": 68 }, + "wind": { "speed": 3.39, "deg": 18, "gust": 4.38 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-24 15:00:00" + }, + { + "dt": 1724522400, + "main": { + "temp": 19.57, + "feels_like": 19.02, + "temp_min": 19.57, + "temp_max": 19.57, + "pressure": 1021, + "sea_level": 1021, + "grnd_level": 998, + "humidity": 55, + "temp_kf": 0 + }, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "clouds": { "all": 82 }, + "wind": { "speed": 3.11, "deg": 41, "gust": 3.66 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-24 18:00:00" + }, + { + "dt": 1724533200, + "main": { + "temp": 17.63, + "feels_like": 17.07, + "temp_min": 17.63, + "temp_max": 17.63, + "pressure": 1021, + "sea_level": 1021, + "grnd_level": 998, + "humidity": 62, + "temp_kf": 0 + }, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03n" + } + ], + "clouds": { "all": 42 }, + "wind": { "speed": 2.53, "deg": 46, "gust": 2.55 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-24 21:00:00" + }, + { + "dt": 1724544000, + "main": { + "temp": 16.52, + "feels_like": 16, + "temp_min": 16.52, + "temp_max": 16.52, + "pressure": 1022, + "sea_level": 1022, + "grnd_level": 998, + "humidity": 68, + "temp_kf": 0 + }, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02n" + } + ], + "clouds": { "all": 21 }, + "wind": { "speed": 2.46, "deg": 46, "gust": 2.46 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-25 00:00:00" + }, + { + "dt": 1724554800, + "main": { + "temp": 15.61, + "feels_like": 15.11, + "temp_min": 15.61, + "temp_max": 15.61, + "pressure": 1022, + "sea_level": 1022, + "grnd_level": 999, + "humidity": 72, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 7 }, + "wind": { "speed": 2.36, "deg": 50, "gust": 2.42 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-25 03:00:00" + }, + { + "dt": 1724565600, + "main": { + "temp": 22.59, + "feels_like": 22.16, + "temp_min": 22.59, + "temp_max": 22.59, + "pressure": 1022, + "sea_level": 1022, + "grnd_level": 999, + "humidity": 48, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 5 }, + "wind": { "speed": 2.58, "deg": 70, "gust": 3.41 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-25 06:00:00" + }, + { + "dt": 1724576400, + "main": { + "temp": 27.57, + "feels_like": 26.98, + "temp_min": 27.57, + "temp_max": 27.57, + "pressure": 1021, + "sea_level": 1021, + "grnd_level": 998, + "humidity": 34, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.9, "deg": 63, "gust": 2.91 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-25 09:00:00" + }, + { + "dt": 1724587200, + "main": { + "temp": 29.79, + "feels_like": 28.49, + "temp_min": 29.79, + "temp_max": 29.79, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 29, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.8, "deg": 42, "gust": 2.38 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-25 12:00:00" + }, + { + "dt": 1724598000, + "main": { + "temp": 28.34, + "feels_like": 27.57, + "temp_min": 28.34, + "temp_max": 28.34, + "pressure": 1019, + "sea_level": 1019, + "grnd_level": 997, + "humidity": 34, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.8, "deg": 51, "gust": 3.94 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-25 15:00:00" + }, + { + "dt": 1724608800, + "main": { + "temp": 21.69, + "feels_like": 21.22, + "temp_min": 21.69, + "temp_max": 21.69, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 50, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.52, "deg": 51, "gust": 4.3 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-25 18:00:00" + }, + { + "dt": 1724619600, + "main": { + "temp": 19.54, + "feels_like": 18.96, + "temp_min": 19.54, + "temp_max": 19.54, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 54, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.21, "deg": 83, "gust": 4.27 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-25 21:00:00" + }, + { + "dt": 1724630400, + "main": { + "temp": 18.22, + "feels_like": 17.59, + "temp_min": 18.22, + "temp_max": 18.22, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 57, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.64, "deg": 94, "gust": 2.68 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-26 00:00:00" + }, + { + "dt": 1724641200, + "main": { + "temp": 17.37, + "feels_like": 16.7, + "temp_min": 17.37, + "temp_max": 17.37, + "pressure": 1021, + "sea_level": 1021, + "grnd_level": 997, + "humidity": 59, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.26, "deg": 102, "gust": 2.29 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-26 03:00:00" + }, + { + "dt": 1724652000, + "main": { + "temp": 24.86, + "feels_like": 24.5, + "temp_min": 24.86, + "temp_max": 24.86, + "pressure": 1020, + "sea_level": 1020, + "grnd_level": 997, + "humidity": 42, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.99, "deg": 89, "gust": 3.93 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-26 06:00:00" + }, + { + "dt": 1724662800, + "main": { + "temp": 30.18, + "feels_like": 28.85, + "temp_min": 30.18, + "temp_max": 30.18, + "pressure": 1019, + "sea_level": 1019, + "grnd_level": 997, + "humidity": 29, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.1, "deg": 94, "gust": 3.16 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-26 09:00:00" + }, + { + "dt": 1724673600, + "main": { + "temp": 32.03, + "feels_like": 30.27, + "temp_min": 32.03, + "temp_max": 32.03, + "pressure": 1018, + "sea_level": 1018, + "grnd_level": 995, + "humidity": 24, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 2.47, "deg": 74, "gust": 2.65 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-26 12:00:00" + }, + { + "dt": 1724684400, + "main": { + "temp": 30.54, + "feels_like": 29.27, + "temp_min": 30.54, + "temp_max": 30.54, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 995, + "humidity": 30, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.19, "deg": 38, "gust": 4.62 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-26 15:00:00" + }, + { + "dt": 1724695200, + "main": { + "temp": 23.64, + "feels_like": 23.18, + "temp_min": 23.64, + "temp_max": 23.64, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 995, + "humidity": 43, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 4.06, "deg": 49, "gust": 8.33 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-26 18:00:00" + }, + { + "dt": 1724706000, + "main": { + "temp": 21.72, + "feels_like": 21.23, + "temp_min": 21.72, + "temp_max": 21.72, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 995, + "humidity": 49, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 0 }, + "wind": { "speed": 3.17, "deg": 91, "gust": 4.65 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-26 21:00:00" + }, + { + "dt": 1724716800, + "main": { + "temp": 20.71, + "feels_like": 20.17, + "temp_min": 20.71, + "temp_max": 20.71, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 995, + "humidity": 51, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ], + "clouds": { "all": 1 }, + "wind": { "speed": 3.01, "deg": 108, "gust": 4.5 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "n" }, + "dt_txt": "2024-08-27 00:00:00" + }, + { + "dt": 1724727600, + "main": { + "temp": 19.78, + "feels_like": 19.2, + "temp_min": 19.78, + "temp_max": 19.78, + "pressure": 1017, + "sea_level": 1017, + "grnd_level": 994, + "humidity": 53, + "temp_kf": 0 + }, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": { "all": 6 }, + "wind": { "speed": 3.03, "deg": 125, "gust": 3.89 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-27 03:00:00" + }, + { + "dt": 1724738400, + "main": { + "temp": 26.94, + "feels_like": 26.46, + "temp_min": 26.94, + "temp_max": 26.94, + "pressure": 1018, + "sea_level": 1018, + "grnd_level": 995, + "humidity": 32, + "temp_kf": 0 + }, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": { "all": 15 }, + "wind": { "speed": 3.83, "deg": 162, "gust": 5.63 }, + "visibility": 10000, + "pop": 0, + "sys": { "pod": "d" }, + "dt_txt": "2024-08-27 06:00:00" + } + ], + "city": { + "id": 515012, + "name": "Oryol", + "coord": { "lat": 52.9685, "lon": 36.0692 }, + "country": "RU", + "population": 324200, + "timezone": 10800, + "sunrise": 1724293760, + "sunset": 1724345291 + } +} diff --git a/gallery/painting/openweather/openweather.py b/gallery/painting/openweather/openweather.py new file mode 100644 index 0000000..37791ad --- /dev/null +++ b/gallery/painting/openweather/openweather.py @@ -0,0 +1,83 @@ +import json + +from pydantic import BaseModel, Field + +from gallery.sketch.source import ApiSource + + +class Model(BaseModel): + class Config: + use_enum_values = True + + +class Main(Model): + temp: float + feels_like: float + temp_min: float + temp_max: float + pressure: int + sea_level: int + grnd_level: int + humidity: int + temp_kf: float + + +class Weather(Model): + id: int + main: str + description: str + icon: str + + +class Clouds(Model): + all: int + + +class Wind(Model): + speed: float + deg: int + gust: float + + +class Rain(Model): + interval_3h: float = Field(..., alias="3h") + + +class Sys(Model): + pod: str + + +class ForecastItem(Model): + dt: int + main: Main + weather: list[Weather] + clouds: Clouds + wind: Wind + visibility: int + pop: float + rain: Rain | None = None + sys: Sys + dt_txt: str + + +class Forecast(Model): + cod: str + message: int + cnt: int + list: list[ForecastItem] + + +class OpenWeather: + BASE_URL = "https://api.openweathermap.org" + + def __init__(self, api_key: str): + self._api_key = api_key + self._source = ApiSource(self.BASE_URL) + + async def get_forecast(self, lat: float, lon: float) -> Forecast: + endpoint = ( + f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric" + ) + response = await self._source.request(endpoint) + response_data = json.loads(response) + return Forecast(**response_data) diff --git a/gallery/painting/openweather/parser.py b/gallery/painting/openweather/parser.py new file mode 100644 index 0000000..3083b0d --- /dev/null +++ b/gallery/painting/openweather/parser.py @@ -0,0 +1,52 @@ +import datetime + +from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue +from gallery.sketch.weather.util import build_weather_value + +from .openweather import ForecastItem + + +class ForecastItemParser: + CLOUDNESS_MAP: dict[str, Cloudness] = { + "clear sky": Cloudness.CLEAR, + "few clouds": Cloudness.PARTLY_CLOUDY, + "scattered clouds": Cloudness.PARTLY_CLOUDY, + "broken clouds": Cloudness.CLOUDY, + "overcast clouds": Cloudness.MAINLY_CLOUDY, + "light rain": Cloudness.CLOUDY, + } + + PRECIPITATION_MAP: dict[str, Precipitation] = { + "light rain": Precipitation.SMALL_RAIN, + "rain": Precipitation.RAIN, + "heavy rain": Precipitation.SHOWER, + } + + def parse(self, item: ForecastItem) -> WeatherValue: + item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC) + item_date = ( + item_date.replace(tzinfo=datetime.timezone.utc) + .astimezone(tz=None) + .replace(tzinfo=None) + ) + value = build_weather_value(item_date) + # TODO parse temperature interval flag + value.temperature = [round(item.main.temp)] + # value.temperature = [round(item.main.temp_max), round(item.main.temp_min)] + value.pressure = [round(item.main.pressure / 133.3 * 100)] + value.humidity = item.main.humidity + value.wind_speed = round(item.wind.speed) + value.wind_gust = round(item.wind.gust) + value.wind_direction = item.wind.deg + value.sky.cloudness = self.CLOUDNESS_MAP.get( + item.weather[0].description, Cloudness.CLEAR + ) + value.sky.precipitation = self.PRECIPITATION_MAP.get( + item.weather[0].description, Precipitation.NO + ) + if item.rain: + value.precipitation = round(item.rain.interval_3h, 1) + return value + + +FORECAST_ITEM_PARSER = ForecastItemParser() diff --git a/gallery/sketch/api.py b/gallery/sketch/api.py index f7d26a5..3ee96ea 100644 --- a/gallery/sketch/api.py +++ b/gallery/sketch/api.py @@ -1,6 +1,12 @@ +from typing import TypeVar + + class Api: PROVIDER: str @property def provider(self) -> str: return self.PROVIDER + + +API = TypeVar("API", bound=Api) diff --git a/gallery/sketch/bundle.py b/gallery/sketch/bundle.py new file mode 100644 index 0000000..3c21c1e --- /dev/null +++ b/gallery/sketch/bundle.py @@ -0,0 +1,30 @@ +from typing import Type + +from .api import API, Api +from .schedule.api import ScheduleApi +from .weather.api import WeatherApi + + +class ApiBundle(list[Api]): + def __init__(self, values: list[Api]) -> None: + super().__init__(values) + + def get_api_by_provider(self, provider: str) -> Api: + for value in self: + if value.PROVIDER == provider: + return value + raise ValueError(provider) + + def get_api_by_type(self, api_type: Type[API]) -> API: + for value in self: + if isinstance(value, api_type): + return value + raise ValueError(api_type) + + @property + def weather(self) -> WeatherApi: + return self.get_api_by_type(WeatherApi) + + @property + def schedule(self) -> ScheduleApi: + return self.get_api_by_type(ScheduleApi) diff --git a/gallery/sketch/cached.py b/gallery/sketch/cached.py index 624810f..290babc 100644 --- a/gallery/sketch/cached.py +++ b/gallery/sketch/cached.py @@ -1,10 +1,8 @@ -from typing import Generic, TypeVar +from typing import Generic from gallery.util import TimeUnit -from .api import Api - -API = TypeVar("API", bound=Api) +from .api import API, Api class CachedApi(Api, Generic[API]): diff --git a/gallery/sketch/catalog.py b/gallery/sketch/catalog.py index e4f519f..c0d9afc 100644 --- a/gallery/sketch/catalog.py +++ b/gallery/sketch/catalog.py @@ -7,5 +7,8 @@ class CatalogBundle(Generic[T]): def __init__(self, items: list[T]) -> None: self._items_by_id = {item.id: item for item in items} + def get_item(self, item_id: str) -> T: + return self._items_by_id[item_id] + def select_items(self, ids: list[str]) -> list[T]: return [self._items_by_id[id_] for id_ in ids] diff --git a/gallery/sketch/mock.py b/gallery/sketch/mock.py index 7de591c..4b18c52 100644 --- a/gallery/sketch/mock.py +++ b/gallery/sketch/mock.py @@ -6,9 +6,12 @@ class MockData: def __init__(self, data_dir) -> None: self._data_dir = data_dir + def get_text(self, key: str) -> str: + return (self._data_dir / f"{key}").read_text() + def get_html(self, key: str) -> str: - return (self._data_dir / f"{key}.html").read_text() + return self.get_text(f"{key}.html") def get_json(self, key: str) -> dict: - data = json.loads((self._data_dir / f"{key}.json").read_text()) + data = json.loads(self.get_text(f"{key}.json")) return data diff --git a/gallery/sketch/weather/catalog.py b/gallery/sketch/weather/catalog.py index 97aa74d..3de4a91 100644 --- a/gallery/sketch/weather/catalog.py +++ b/gallery/sketch/weather/catalog.py @@ -15,7 +15,17 @@ class LocationId(str, Enum): BUNDLE = CatalogBundle( [ - Location(id=LocationId.OREL, name="Орёл"), - Location(id=LocationId.ZMIYEVKA, name="Змиёвка"), + Location( + id=LocationId.OREL, + name="Орёл", + lat=52.9687747, + lon=36.0694937, + ), + Location( + id=LocationId.ZMIYEVKA, + name="Змиёвка", + lat=52.672192, + lon=36.380112, + ), ] ) diff --git a/gallery/sketch/weather/model.py b/gallery/sketch/weather/model.py index c786fa6..7432bdc 100644 --- a/gallery/sketch/weather/model.py +++ b/gallery/sketch/weather/model.py @@ -12,6 +12,8 @@ class Model(BaseModel): class Location(Model): id: str name: str + lat: float + lon: float class Cloudness(str, Enum): @@ -38,22 +40,69 @@ class Sky(Model): class WindDirection(str, Enum): CALM = "calm" N = "N" - NO = "NO" - O = "O" - SO = "SO" + NE = "NE" + E = "E" + SE = "SE" S = "S" SW = "SW" W = "W" NW = "NW" +class WindDirectionDeg(float): + @property + def direction(self) -> WindDirection: + return self.to_direction() + + @property + def value(self) -> float: + return self + + # pylint:disable=too-many-return-statements + def to_direction(self) -> WindDirection: + if self > 337.5 or self <= 22.25: + return WindDirection.N + elif self <= 67.5: + return WindDirection.NE + elif self <= 112.5: + return WindDirection.E + elif self <= 157.5: + return WindDirection.SE + elif self <= 202.5: + return WindDirection.S + elif self <= 247.5: + return WindDirection.SW + elif self <= 292.5: + return WindDirection.W + elif self <= 337.5: + return WindDirection.NW + else: + return WindDirection.CALM + + @classmethod + def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg": + return cls( + { + WindDirection.CALM: -1, + WindDirection.N: 0, + WindDirection.NE: 45, + WindDirection.E: 90, + WindDirection.SE: 135, + WindDirection.S: 180, + WindDirection.SW: 225, + WindDirection.W: 270, + WindDirection.NW: 315, + }[direction] + ) + + class WeatherValue(Model): date: datetime.datetime sky: Sky temperature: list[int] wind_speed: int wind_gust: int - wind_direction: WindDirection + wind_direction: float precipitation: float pressure: list[int] humidity: int diff --git a/gallery/sketch/weather/util.py b/gallery/sketch/weather/util.py index 767b668..dbaec21 100644 --- a/gallery/sketch/weather/util.py +++ b/gallery/sketch/weather/util.py @@ -1,6 +1,7 @@ import datetime +import statistics -from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection +from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg def build_weather_value(date: datetime.datetime) -> WeatherValue: @@ -15,8 +16,49 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue: temperature=[], wind_speed=0, wind_gust=0, - wind_direction=WindDirection.CALM, + wind_direction=WindDirectionDeg(-1), precipitation=0, pressure=[], humidity=0, ) + + +def merge_weather_values( + date: datetime.datetime, values: list[WeatherValue] +) -> WeatherValue: + result = build_weather_value(date) + temperatures = [] + pressures = [] + humidities = [] + wind_speeds = [] + wind_gusts = [] + wind_directions = [] + cloudnesses = [] + precipitations = [] + precipitation = 0 + for value in values: + temperatures += value.temperature + pressures += value.pressure + humidities.append(value.humidity) + wind_speeds.append(value.wind_speed) + wind_gusts.append(value.wind_gust) + wind_directions.append(value.wind_direction) + cloudnesses.append(value.sky.cloudness) + precipitations.append(value.sky.precipitation) + precipitation += value.precipitation + result.temperature = [max(temperatures), min(temperatures)] + result.pressure = [max(pressures), min(pressures)] + result.humidity = round(statistics.mean(humidities)) + result.wind_speed = round(statistics.mean(wind_speeds)) + result.wind_gust = round(statistics.mean(wind_gusts)) + result.wind_direction = statistics.mean(wind_directions) + # TODO: merge cloudnesses + for item in cloudnesses: + if item != Cloudness.CLEAR: + result.sky.cloudness = item + # TODO: merge precipitations + for item in precipitations: + if item != Precipitation.NO: + result.sky.precipitation = item + result.precipitation = precipitation + return result diff --git a/tests/test_openweather_api.py b/tests/test_openweather_api.py new file mode 100644 index 0000000..67e55b6 --- /dev/null +++ b/tests/test_openweather_api.py @@ -0,0 +1,27 @@ +import datetime + +import pytest + +from gallery.painting.openweather.api import OpenWeatherApi +from gallery.painting.openweather.mock import OPENWEATHER_MOCK_DATA +from gallery.painting.openweather.openweather import Forecast + + +@pytest.fixture(name="openweather_api", scope="module") +def openweather_api_fixture() -> OpenWeatherApi: + async def _get_location_forecast(location_id: str) -> Forecast: + return Forecast(**OPENWEATHER_MOCK_DATA.get_json("forecast")) + + api = OpenWeatherApi() + api._get_location_forecast = _get_location_forecast + return api + + +async def test_day(openweather_api: OpenWeatherApi): + result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23)) + assert len(result.values) == 8 + + +async def test_days(openweather_api: OpenWeatherApi): + result = await openweather_api.get_days("orel-4432", 10) + assert len(result.values) == 6