feat(weather): add weather location search

This commit is contained in:
2026-04-22 12:58:56 +03:00
parent 3dd0a5410c
commit 94870a5c86
32 changed files with 550 additions and 152 deletions

View File

@@ -3,14 +3,16 @@ import datetime
from fastapi import FastAPI
from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse
from gallery.sketch.weather.model import Location, WeatherResponse
def mount(app: FastAPI):
@app.get("/api/weather/locations")
async def get_api_weather_locations(request: AppRequest) -> list[str]:
async def get_api_weather_locations(
request: AppRequest, query: str
) -> list[Location]:
weather_api = request.app.state.api.weather
return await weather_api.get_locations()
return await weather_api.find_locations(query)
@app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day(

View File

@@ -7,8 +7,6 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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
from gallery.version import __version__
@@ -41,16 +39,15 @@ def mount(app: FastAPI):
)
@app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: AppRequest):
async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations)
locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"locations": locations_data,
"locations": locations,
},
)
@@ -58,16 +55,6 @@ def mount(app: FastAPI):
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
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: 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: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather

View File

@@ -12,9 +12,24 @@
{% block header %}Погода{% endblock %}
{% block content %}
<form action=""
method="get"
style="margin: 0 auto 1rem;">
<input id="query"
name="query">
<button type="submit">Search</button>
</form>
<ul class="app-list">
{% for location in locations %}
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
<li>
<a href="weather/{{location.id}}">
<span>{{location.name}}</span>
<span style="font-size: 70%; margin-left: 0.5rem;">
{{location.country}}, {{location.district}}, {{location.subdistrict}}
</span>
<span></span>
</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -1,13 +1,13 @@
import datetime
import json
import logging
from typing import Any, Dict, List
from typing import Any
from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -34,7 +34,7 @@ class GismeteoApi(WeatherApi):
)
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +52,7 @@ class GismeteoApi(WeatherApi):
)
def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup)
@@ -69,11 +69,29 @@ class GismeteoApi(WeatherApi):
values=values,
)
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
async def find_locations(self, query: str) -> list[Location]:
geo = "ru"
latitude = 52.968498
longitude = 36.0695
data = json.loads(
await self.SOURCE.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
)
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
district=item["translations"]["kk"]["district"]["name"],
subdistrict=item["translations"]["kk"]["subdistrict"]["name"],
)
)
return result
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -5,8 +5,7 @@ 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.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
@@ -20,11 +19,9 @@ class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
@classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]:
return tuple(map(float, location_id.split(":", maxsplit=2)))
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
@@ -32,8 +29,10 @@ class OpenWeatherApi(WeatherApi):
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)
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
@@ -42,9 +41,8 @@ class OpenWeatherApi(WeatherApi):
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,
location=location_id,
date=date,
period="day",
values=values,
@@ -61,9 +59,8 @@ class OpenWeatherApi(WeatherApi):
merge_weather_values(date, values)
for date, values in values_by_date.items()
]
location = BUNDLE.get_item(location_id)
return WeatherResponse(
location=location.name,
location=location_id,
date=datetime.date.today(),
period="days",
values=list(sorted(values, key=lambda item: item.date)),

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
YANDEXTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -1,17 +0,0 @@
import json
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.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads(self.get_text(f"{key}.json"))
return data

View File

@@ -1,12 +1,12 @@
import datetime
from ..api import Api
from .model import WeatherResponse
from .model import Location, WeatherResponse
class WeatherApi(Api):
async def get_locations(self) -> list[str]:
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:

View File

@@ -5,7 +5,7 @@ from aiocache import cached
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
from .api import WeatherApi
from .model import WeatherResponse
from .model import Location, WeatherResponse
CACHE_PRESET = DEFAULT_CACHE_PRESET
@@ -14,11 +14,11 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather"
@cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations",
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
**CACHE_PRESET._asdict(),
)
async def get_locations(self) -> list[str]:
return await self._api.get_locations()
async def find_locations(self, query: str) -> list[Location]:
return await self._api.find_locations(query)
@cached(
key_builder=lambda fun, self, location_id, date: (

View File

@@ -1,31 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Location
class LocationId(str, Enum):
OREL = "orel-4432"
ZMIYEVKA = "zmiyevka-184640"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Location(
id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

View File

@@ -1,12 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
from gallery.sketch.weather.model import WeatherResponse
class WeatherMockData(MockData):
def get_response(self, key: str) -> WeatherResponse:
return WeatherResponse(**self.get_json(key))
WEATHER_MOCK_DATA = WeatherMockData(Path(__file__).parent / "data")

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"day","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[20],"wind_speed":1,"wind_gust":1,"wind_direction":"SW","precipitation":0.0,"pressure":[744],"humidity":85},{"date":"2024-07-29T03:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[18],"wind_speed":1,"wind_gust":1,"wind_direction":"W","precipitation":0.6,"pressure":[742],"humidity":96},{"date":"2024-07-29T06:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":1,"wind_gust":2,"wind_direction":"S","precipitation":4.9,"pressure":[741],"humidity":95},{"date":"2024-07-29T09:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":7,"wind_direction":"S","precipitation":3.8,"pressure":[740],"humidity":83},{"date":"2024-07-29T12:00:00","sky":{"cloudness":"clear","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":0.0,"pressure":[740],"humidity":54},{"date":"2024-07-29T15:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[738],"humidity":48},{"date":"2024-07-29T18:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":63},{"date":"2024-07-29T21:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[17],"wind_speed":3,"wind_gust":7,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":77}]}

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"days","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[21,17],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":9.3,"pressure":[744,737],"humidity":96},{"date":"2024-07-30T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":true,"fog":false},"temperature":[19,14],"wind_speed":2,"wind_gust":7,"wind_direction":"N","precipitation":11.0,"pressure":[737,733],"humidity":100},{"date":"2024-07-31T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[22,14],"wind_speed":3,"wind_gust":10,"wind_direction":"NW","precipitation":1.8,"pressure":[741,738],"humidity":99},{"date":"2024-07-01T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":10,"wind_direction":"W","precipitation":0.1,"pressure":[741,740],"humidity":97},{"date":"2024-07-02T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,17],"wind_speed":2,"wind_gust":8,"wind_direction":"W","precipitation":0.2,"pressure":[740],"humidity":84},{"date":"2024-07-03T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":1,"wind_gust":4,"wind_direction":"N","precipitation":0.0,"pressure":[740,739],"humidity":99},{"date":"2024-07-04T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":3,"wind_gust":6,"wind_direction":"N","precipitation":0.0,"pressure":[743,740],"humidity":92},{"date":"2024-07-05T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":true,"fog":false},"temperature":[25,15],"wind_speed":3,"wind_gust":7,"wind_direction":"NW","precipitation":2.1,"pressure":[744,743],"humidity":98},{"date":"2024-07-06T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":5,"wind_direction":"NW","precipitation":0.3,"pressure":[745,744],"humidity":98},{"date":"2024-07-07T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[26,14],"wind_speed":2,"wind_gust":5,"wind_direction":"NW","precipitation":0.2,"pressure":[747,745],"humidity":95}]}

View File

@@ -14,6 +14,9 @@ class Location(Model):
name: str
lat: float
lon: float
country: str
district: str
subdistrict: str
class Cloudness(str, Enum):

0
tests/common/__init__.py Normal file
View File

17
tests/common/mock.py Normal file
View File

@@ -0,0 +1,17 @@
from pathlib import Path
from gallery.sketch.source import ApiSource
class MockSource(ApiSource):
def __init__(self, path: Path, mapping: dict[str, str]):
super().__init__("")
self._path = path
self._mapping = mapping
async def request(self, endpoint: str) -> str:
for pattern, filename in self._mapping.items():
if pattern in endpoint:
return (self._path / filename).read_text()
raise ValueError(endpoint)

View File

@@ -0,0 +1,12 @@
from pathlib import Path
from tests.common.mock import MockSource
GISMETEO_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"today": "today.html",
"10-days": "10-days.html",
"mq/city/q": "mq_city_q.json",
},
)

View File

@@ -0,0 +1,400 @@
{
"meta": { "status": true },
"data": [
{
"id": 4432,
"kind": "M",
"slug": "orel",
"coordinates": { "latitude": 52.968498, "longitude": 36.0695 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 13074,
"kind": "A",
"slug": "orel-yuzhnyy-im-i-s-turgeneva",
"coordinates": { "latitude": 52.935001, "longitude": 36.001671 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112316,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.0172, "longitude": 30.849199 },
"obsStationId": 12921,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1828, "slug": "loyev-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 178290,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 58.799999, "longitude": 34.453701 },
"obsStationId": 11657,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 248, "slug": "novgorod-oblast" },
"subdistrict": { "id": 2857, "slug": "municipal-district-khvoyninsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112830,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.182499, "longitude": 30.4349 },
"obsStationId": 12920,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1833, "slug": "rechytsa-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97816,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 49.088799, "longitude": 36.228401 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1646, "slug": "berestyn-district" },
"translations": {
"ru": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
},
"kk": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97619,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.980499, "longitude": 36.0075 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1649, "slug": "lozivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 78141,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.945999, "longitude": 35.689098 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1184, "slug": "samarivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 77735,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 48.587799, "longitude": 34.8111 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1178, "slug": "dniprovskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
},
"kk": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 171956,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 55.516499, "longitude": 44.0658 },
"obsStationId": 11899,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 266, "slug": "nizhny-novgorod-oblast" },
"subdistrict": { "id": 2796, "slug": "municipal-district-vadsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
}
],
"error": null
}

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
MATCHTV_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"test": "test.html",
},
)

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
OPENWEATHER_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"forecast": "forecast.json",
},
)

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
YANDEXTV_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"test": "test.html",
},
)

View File

@@ -3,20 +3,21 @@ import datetime
import pytest
from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.gismeteo.mock import GISMETEO_MOCK_DATA
from tests.data.gismeteo import GISMETEO_MOCK_SOURCE
@pytest.fixture(name="gismeteo_api", scope="module")
def gismeteo_api_fixture() -> GismeteoApi:
class MockSource:
async def request(self, endpoint: str):
return GISMETEO_MOCK_DATA.get_html(endpoint.split("/")[-1])
api = GismeteoApi()
api.SOURCE = MockSource()
api.SOURCE = GISMETEO_MOCK_SOURCE
return api
async def test_search(gismeteo_api: GismeteoApi):
result = await gismeteo_api.find_locations("test")
assert len(result) == 10
async def test_day(gismeteo_api: GismeteoApi):
result = await gismeteo_api.get_day("test", datetime.date.today())
assert len(result.values) == 8

View File

@@ -3,18 +3,14 @@ import datetime
import pytest
from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.matchtv.mock import MATCHTV_MOCK_DATA
from gallery.sketch.schedule.model import ChannelId
from tests.data.matchtv import MATCHTV_MOCK_SOURCE
@pytest.fixture(name="matchtv_api", scope="module")
def matchtv_api_fixture() -> MatchTvApi:
class MockSource:
async def request(self, endpoint: str):
return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1].split("?")[0])
api = MatchTvApi()
api.SOURCE = MockSource()
api.SOURCE = MATCHTV_MOCK_SOURCE
return api

View File

@@ -3,25 +3,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
from gallery.painting.openweather.openweather import OpenWeather
from tests.data.openweather import OPENWEATHER_MOCK_SOURCE
@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"))
class MockOpenWeather(OpenWeather):
def __init__(self):
super().__init__("")
self._source = OPENWEATHER_MOCK_SOURCE
api = OpenWeatherApi()
api._get_location_forecast = _get_location_forecast
api.SOURCE = MockOpenWeather()
return api
async def test_day(openweather_api: OpenWeatherApi):
result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23))
result = await openweather_api.get_day("52.968498:36.0695", 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)
result = await openweather_api.get_days("52.968498:36.0695", 10)
assert len(result.values) == 6

View File

@@ -3,19 +3,14 @@ import datetime
import pytest
from gallery.painting.yandextv.api import CHANNELS_MAP, YandexTvApi
from gallery.painting.yandextv.mock import YANDEXTV_MOCK_DATA
from gallery.sketch.schedule.model import ChannelId
from tests.data.yandextv import YANDEXTV_MOCK_SOURCE
@pytest.fixture(name="yandextv_api", scope="module")
def yandextv_api_fixture() -> YandexTvApi:
class MockSource:
async def request(self, endpoint: str):
return YANDEXTV_MOCK_DATA.get_html(endpoint.split("/")[1].split("?")[0])
api = YandexTvApi()
api.SOURCE = MockSource()
api.SOURCE = YANDEXTV_MOCK_SOURCE
CHANNELS_MAP[ChannelId("test")] = "test"
return api