refactor: rename project to gallery

This commit is contained in:
2024-08-11 20:46:04 +03:00
parent 3785b8ce5d
commit 72fd378c40
48 changed files with 117 additions and 92 deletions

0
gallery/__init__.py Normal file
View File

21
gallery/easel/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
import locale
from fastapi import FastAPI
from gallery.sketch.weather.api import WeatherApi
from .route import api, doc, view
def build_app(weather_api: WeatherApi) -> FastAPI:
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
app = FastAPI(
title="Weather",
docs_url=None,
redoc_url=None,
)
app.state.weather_api = weather_api
doc.mount(app)
api.mount(app)
view.mount(app)
return app

View File

View File

@@ -0,0 +1,22 @@
import datetime
from fastapi import FastAPI, Request
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
def mount(app: FastAPI):
@app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day(
request: Request, location: str, date: datetime.date
) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api
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
) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api
return await weather_api.get_days(location, days)

View File

@@ -0,0 +1,37 @@
from anyio import Path
from fastapi import FastAPI
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.staticfiles import StaticFiles
def mount(app: FastAPI):
app.mount(
"/docs/static",
StaticFiles(directory=Path(__file__).parent / "static"),
)
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="docs/static/swagger-ui-bundle.js",
swagger_css_url="docs/static/swagger-ui.css",
)
@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
async def swagger_ui_redirect():
return get_swagger_ui_oauth2_redirect_html()
@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url="docs/static/redoc.standalone.js",
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,57 @@
import datetime
from enum import Enum
from typing import NamedTuple
class TagType(str, Enum):
DAY = "day"
DAYS = "days"
class Tag(NamedTuple):
type: TagType
date: datetime.date
days: int = 1
def __str__(self) -> str:
if self.type == TagType.DAY:
today = datetime.date.today()
day = (self.date - today).days
return f"day-{day}"
elif self.type == TagType.DAYS:
return f"days-{self.days}"
else:
raise ValueError(self.type)
class TagUtil:
@classmethod
def parse_tag(cls, tag: str) -> Tag:
if tag == "today":
return Tag(TagType.DAY, datetime.date.today())
elif tag == "tomorrow":
return Tag(TagType.DAY, datetime.date.today() + datetime.timedelta(days=1))
elif tag.startswith("day-"):
days = int(tag.split("-")[-1])
return Tag(
TagType.DAY,
datetime.date.today() + datetime.timedelta(days=days),
)
elif tag.startswith("days-"):
days = int(tag.split("-")[-1])
return Tag(TagType.DAYS, datetime.date.today(), days)
raise ValueError(tag)
@classmethod
def create_tag(
cls,
tag_type: TagType,
date: datetime.date,
day: int = 0,
days: int = 10,
) -> Tag:
if tag_type == TagType.DAY:
return Tag(tag_type, date + datetime.timedelta(days=day))
if tag_type == TagType.DAYS:
return Tag(tag_type, date, days)
raise ValueError(tag_type)

View File

@@ -0,0 +1,87 @@
import datetime
from pathlib import Path
from fastapi import FastAPI, Request
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.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse
from ..util import TagType, TagUtil
from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static", StaticFiles(directory=base_dir / "static"), name="static")
templates = Jinja2Templates(directory=base_dir / "templates")
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: Request, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@app.get("/", response_class=RedirectResponse)
async def get_weather_root():
return RedirectResponse("weather")
@app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: Request):
weather_api: WeatherApi = request.app.state.weather_api
locations = await weather_api.get_locations()
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"locations": locations,
},
)
@app.get("/weather/{location}", response_class=RedirectResponse)
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: Request):
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):
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
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
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):
tag_value = TagUtil.parse_tag(tag)
weather_api: WeatherApi = request.app.state.weather_api
if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:
response = await weather_api.get_days(location, tag_value.days)
else:
raise ValueError(tag)
return build_weather_response(request, response)

View File

@@ -0,0 +1,39 @@
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
def wind_direction_icon(wind_direction: WindDirection) -> str:
return {
WindDirection.N: "⬇️",
WindDirection.NO: "↙️",
WindDirection.O: "⬅️",
WindDirection.SO: "↖️",
WindDirection.S: "⬆️",
WindDirection.SW: "↗️",
WindDirection.W: "➡️",
WindDirection.NW: "↘️",
WindDirection.CALM: "",
}.get(wind_direction, wind_direction)
def cloudness_icon(sky: Sky) -> list[str]:
main_icon = ""
if sky.thunder:
if sky.cloudness == Cloudness.CLEAR:
main_icon = "🌩️"
if sky.precipitation == Precipitation.NO:
main_icon = ""
else:
main_icon = "⛈️"
elif sky.precipitation == Precipitation.NO:
main_icon = {
Cloudness.CLEAR: "☀️",
Cloudness.PARTLY_CLOUDY: "🌤️",
Cloudness.CLOUDY: "",
Cloudness.MAINLY_CLOUDY: "☁️",
}[sky.cloudness]
else:
main_icon = "🌧️"
icons = [main_icon]
if sky.fog:
icons.append("🌫️")
return icons

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -0,0 +1,116 @@
body {
font-size: 1.5rem;
}
.app-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
}
h3 {
margin: 0.5rem 0;
}
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
table {
/* width: 100%; */
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
/* border: 1px solid rgba(0, 0, 0, 0.2); */
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
.header {
font-size: 1rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.1);
}
.date.now {
background: rgba(0, 128, 255, 0.2);
}
.date .value a {
all: unset;
cursor: pointer;
}
.cloudness {
vertical-align: top;
}
.cloudness .icon {
font-size: 1rem;
}
.cloudness .icon:first-child {
font-size: 2rem;
}
.temperature {
padding: 0;
}
.temperature .value {
padding: 0.1rem 0.4rem;
}
.temperature .value.positive {
color: orangered;
}
.temperature .value.negative {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure {
padding: 0;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Погода</title>
<link rel="stylesheet"
href="/static/style.css?v=1">
<link rel="icon"
href="/static/favicon.ico"
type="image/x-icon">
</head>
<body class="app-container">
<ul>
{% for location in locations %}
<li><a href="weather/{{location}}">{{location}}</a></li>
{% endfor %}
</ul>
</body>
</html>

View File

@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/static/style.css?v=1">
<link rel="icon"
href="/static/favicon.ico"
type="image/x-icon">
</head>
<body class="app-container">
<h3>
{% if response.period == 'day' %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../tag/days-10">⬆️</a>
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
{% endif %}
</h3>
<table>
<tbody>
<!-- date -->
<tr>
{% for value in response.values %}
{% if response.period == 'day' %}
<td
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
<span class="value">{{value.date.strftime('%H:%M')}}</span>
</td>
{% endif %}
{% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
{{value.date.strftime('%a %d')}}
</a>
</span>
</td>
{% endif %}
{% endfor %}
</tr>
<!-- cloudness -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Облачность
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="cloudness">
{% for icon in value.sky | cloudness_icon %}
<div class="icon">{{icon}}</div>
{% endfor %}
</td>
{% endfor %}
</tr>
<!-- temperature -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Температура, °C
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="temperature">
{% for temperature in value.temperature %}
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
{{temperature}}
</div>
{% endfor %}
</td>
{% endfor %}
</tr>
<!-- wind_direction -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Направление ветра
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="wind">
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
</td>
{% endfor %}
</tr>
<!-- wind_speed -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Скорость ветра, м/с
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="wind"
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
<span class="speed">{{value.wind_speed}}</span>
{% if value.wind_gust != value.wind_speed %}
<span class="gust">
({{value.wind_gust}})
</span>
{% endif %}
</td>
{% endfor %}
</tr>
<!-- precipitation -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Осадки, мм
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="precipitation"
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
<span class="value">{{value.precipitation or ''}}</span>
</td>
{% endfor %}
</tr>
<!-- pressure -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Давление, мм рт. ст.
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="pressure">
{% for pressure in value.pressure %}
<div class="value"
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
{{pressure}}</div>
{% endfor %}
</td>
{% endfor %}
</tr>
<!-- humidity -->
<tr>
<td colspan="{{response.values | length}}"
class="header">
Влажность, %
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="humidity"
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
<span class="value">{{value.humidity}}</span>
</td>
{% endfor %}
</tr>
</tbody>
</table>
<script src="/static/index.js"></script>
</body>
</html>

34
gallery/logging.yaml Normal file
View File

@@ -0,0 +1,34 @@
version: 1
disable_existing_loggers: False
formatters:
default:
# "()": uvicorn.logging.DefaultFormatter
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
access:
# "()": uvicorn.logging.AccessFormatter
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handlers:
default:
formatter: default
class: logging.StreamHandler
stream: ext://sys.stderr
access:
formatter: access
class: logging.StreamHandler
stream: ext://sys.stdout
loggers:
uvicorn.error:
level: INFO
handlers:
- default
propagate: no
uvicorn.access:
level: INFO
handlers:
- access
propagate: no
root:
level: INFO
handlers:
- default
propagate: no

19
gallery/main.py Normal file
View File

@@ -0,0 +1,19 @@
from os import environ
from pathlib import Path
import uvicorn
from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi
app = build_app(GismeteoApi())
def run():
uvicorn.run(
"gallery.main:app",
host="0.0.0.0",
port=8000,
log_config=str(Path(__file__).parent / "logging.yaml"),
reload="DEBUG" in environ,
)

View File

View File

View File

@@ -0,0 +1,96 @@
import datetime
import logging
from typing import Any, Dict, List
import aiohttp
from aiocache import cached
from bs4 import BeautifulSoup
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
logger = logging.getLogger("gismeteo")
class GismeteoApi(WeatherApi):
BASE_URL = "https://www.gismeteo.ru"
CACHE_TTL = 10 * 60
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
)
COOKIE = (
"cf_clearance=U28mYVC0ENu88vorlL_CWmWOoevvXp0vb4xCqfqYC9s-"
"1722273367-1.0.1.1-"
"IDV73azTHY0V.NAnmEvok3zf5HHEkvF098pmya7IiqRRB5nk3FhbLCb0AeWm_kpTFqi1niFk2mYN_ramGTSl0A"
)
async def _request(self, endpoint: str) -> str:
url = f"{self.BASE_URL}/{endpoint}"
logger.info(url)
async with aiohttp.ClientSession(
headers={
"User-Agent": self.USER_AGENT,
"Cookie": self.COOKIE,
},
raise_for_status=True,
) as session:
async with session.request("GET", url) as response:
return await response.text()
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup)
for parser in ROW_PARSERS:
for index, value in enumerate(parser.parse_row(widget)):
while len(result) < index + 1:
result.append({})
result[index][parser.KEY] = value
values = [WeatherValue(**item) for item in result]
return WeatherResponse(
location=location or "n/a",
date=date,
period="day",
values=values,
)
def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup)
for parser in ROW_PARSERS:
for index, value in enumerate(parser.parse_row(widget)):
while len(result) < index + 1:
result.append({})
result[index][parser.KEY] = value
values = [WeatherValue(**item) for item in result]
return WeatherResponse(
location=location or "n/a",
date=datetime.date.today(),
period="days",
values=values,
)
async def get_locations(self) -> list[str]:
return [
"orel-4432",
"zmiyevka-184640",
]
@cached(ttl=CACHE_TTL)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data = await self._request(f"weather-{location_id}/{datehelp.dump(date)}")
return self._parse_oneday(date, data)
@cached(ttl=CACHE_TTL)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
data = await self._request(f"weather-{location_id}/{days}-days")
return self._parse_manydays(data)

View File

@@ -0,0 +1,30 @@
from typing import Generic, Iterable, Optional, TypeVar
from bs4 import Tag
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError
class BaseWidgetParser(WidgetParser):
SELECT: str
def __init__(self, select: Optional[str] = None):
super().__init__()
self._select = select or self.SELECT
def parse_widget(self, tag: Tag) -> Tag:
widget = tag.select_one(self._select)
if widget is None:
raise ValueError(self._select)
return widget
class RowParser(Generic[T]):
KEY: str
def parse_row(self, tag: Tag) -> Iterable[T]:
raise NotImplementedError

View File

@@ -0,0 +1,39 @@
import datetime
from enum import Enum
import dateparser
import dateparser.date_parser
class Day(str, Enum):
TODAY = "today"
TOMORROW = "tomorrow"
DAY_3 = "3-day"
DAY_4 = "4-day"
DAY_5 = "5-day"
def parse(value: str) -> datetime.date:
if value in ["today", "mock"]:
return datetime.date.today()
elif value == "tomorrow":
return datetime.date.today() + datetime.timedelta(days=1)
elif value.endswith("-day"):
days = int(value.split("-")[0]) - 1
return datetime.date.today() + datetime.timedelta(days=days)
else:
date = dateparser.parse(value)
if date is None:
raise ValueError(value)
return date.date()
def dump(date: datetime.date) -> str:
today = datetime.date.today()
days = (date - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
else:
return f"{days + 1}-day"

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,186 @@
import datetime
import re
from typing import Iterable
import dateparser
from bs4 import Tag
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
from .core import BaseWidgetParser, RowParser
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
class LocationParser:
PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"')
def parse_location(self, data: str) -> str | None:
match = self.PATTERN.search(data)
if match:
return match.group(1)
return None
LOCATION_PARSER = LocationParser()
class DateParser(RowParser[datetime.datetime]):
KEY = "date"
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_date_tag = tag.select_one(
".widget-row.widget-row-datetime-date > .row-item"
)
if datetime_date_tag:
date_str = datetime_date_tag.find(text=True, recursive=False).text
date = dateparser.parse(date_str, languages=["ru"])
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
time_str = item.text
time = dateparser.parse(time_str, languages=["ru"])
time = time.replace(year=date.year, month=date.month, day=date.day)
yield time
else:
for item in tag.select(".widget-row.widget-row-date > .row-item"):
date_str = item.text
date = dateparser.parse(date_str, languages=["ru"])
yield date
class SkyParser(RowParser[Sky]):
KEY = "sky"
CLOUDNESS_MAP: dict[str, Cloudness] = {
"ясно": Cloudness.CLEAR,
"малооблачно": Cloudness.PARTLY_CLOUDY,
"облачно": Cloudness.CLOUDY,
"пасмурно": Cloudness.MAINLY_CLOUDY,
}
PRECIPITATION_MAP: dict[str, Precipitation] = {
"без осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN,
"дождь": Precipitation.RAIN,
"ливень": Precipitation.SHOWER,
}
def parse_row(self, tag: Tag) -> Iterable[Sky]:
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
sky_str = item.attrs["data-tooltip"]
values = {item.strip().lower() for item in sky_str.split(",")}
cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO
thunder = "гроза" in values
fog = "дымка" in values
for k, v in self.CLOUDNESS_MAP.items():
if k in values:
cloudness = v
break
for k, v in self.PRECIPITATION_MAP.items():
if k in values:
precipitation = v
break
yield Sky(
cloudness=cloudness,
precipitation=precipitation,
thunder=thunder,
fog=fog,
)
class TemperatureParser(RowParser[list[int]]):
KEY = "temperature"
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select(
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
):
yield [
int(value.attrs["value"]) for value in item.select("temperature-value")
]
class WindSpeedParser(RowParser[int]):
KEY = "wind_speed"
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(
".widget-row[data-row=wind-speed] > .row-item > speed-value"
):
yield int(item.attrs["value"])
class WindGustParser(RowParser[int]):
KEY = "wind_gust"
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row[data-row=wind-gust] > .row-item"):
value = item.select_one("speed-value")
yield int(value.attrs["value"]) if value else 0
class WindDirectionParser(RowParser[WindDirection]):
KEY = "wind_direction"
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"штиль": WindDirection.CALM,
"с": WindDirection.N,
"св": WindDirection.NO,
"в": WindDirection.O,
"юв": WindDirection.SO,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
def parse_row(self, tag: Tag) -> Iterable[WindDirection]:
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]
class WindPrecipitationParser(RowParser[float]):
KEY = "precipitation"
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
):
yield float(item.text.replace(",", "."))
class PressureParser(RowParser[list[int]]):
KEY = "pressure"
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select(
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
):
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
class HumidityParser(RowParser[int]):
KEY = "humidity"
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row[data-row=humidity] > .row-item"):
yield int(item.text)
ROW_PARSERS: list[RowParser] = [
DateParser(),
SkyParser(),
TemperatureParser(),
WindSpeedParser(),
WindGustParser(),
WindDirectionParser(),
WindPrecipitationParser(),
PressureParser(),
HumidityParser(),
]
ROW_PARSERS_MAP: dict[str, RowParser] = {parser.KEY: parser for parser in ROW_PARSERS}

15
gallery/sketch/mock.py Normal file
View File

@@ -0,0 +1,15 @@
import json
from pathlib import Path
class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_html(self, key: str) -> str:
return (self._data_dir / f"{key}.html").read_text()
def get_json(self, key: str) -> dict:
data = json.loads((Path(__file__).parent / f"data/{key}.json").read_text())
return data

View File

View File

@@ -0,0 +1,26 @@
import datetime
from pydantic import BaseModel
class Model(BaseModel):
class Config:
use_enum_values = True
class Channel(Model):
id: str
name: str
class ScheduleItem(Model):
start: datetime.datetime
end: datetime.datetime
label: str
category: str | None
class Schedule(Model):
channel: Channel
date: datetime.date
items: list[ScheduleItem]

View File

View File

@@ -0,0 +1,14 @@
import datetime
from .model import WeatherResponse
class WeatherApi:
async def get_locations(self) -> list[str]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
raise NotImplementedError
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
raise NotImplementedError

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1 @@
{"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

@@ -0,0 +1 @@
{"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

@@ -0,0 +1,61 @@
import datetime
from enum import Enum
from pydantic import BaseModel
class Model(BaseModel):
class Config:
use_enum_values = True
class Cloudness(str, Enum):
CLEAR = "clear"
PARTLY_CLOUDY = "party_cloudy"
CLOUDY = "cloudy"
MAINLY_CLOUDY = "mainly_cloudy"
class Precipitation(str, Enum):
NO = "no"
SMALL_RAIN = "small_rain"
RAIN = "rain"
SHOWER = "shower"
class Sky(Model):
cloudness: Cloudness
precipitation: Precipitation
thunder: bool
fog: bool
class WindDirection(str, Enum):
CALM = "calm"
N = "N"
NO = "NO"
O = "O"
SO = "SO"
S = "S"
SW = "SW"
W = "W"
NW = "NW"
class WeatherValue(Model):
date: datetime.datetime
sky: Sky
temperature: list[int]
wind_speed: int
wind_gust: int
wind_direction: WindDirection
precipitation: float
pressure: list[int]
humidity: int
class WeatherResponse(Model):
location: str
date: datetime.date
period: str
values: list[WeatherValue]

View File

@@ -0,0 +1,22 @@
import datetime
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection
def build_weather_value(date: datetime.datetime) -> WeatherValue:
return WeatherValue(
date=date,
sky=Sky(
cloudness=Cloudness.CLEAR,
precipitation=Precipitation.NO,
thunder=False,
fog=False,
),
temperature=[],
wind_speed=0,
wind_gust=0,
wind_direction=WindDirection.CALM,
precipitation=0,
pressure=[],
humidity=0,
)