3 Commits

Author SHA1 Message Date
ad8144df37 feat(easel): add base template 2026-04-12 17:37:36 +03:00
f303d0e1f4 fix(gismeteo): fix gismeteo parser 2026-04-12 16:27:02 +03:00
3e80ccb0df feat(weather): add openweather api 2024-08-25 23:28:49 +03:00
38 changed files with 2093 additions and 524 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -1,19 +1,29 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": ".",
} },
], ],
"settings": { "settings": {
"python.testing.pytestArgs": ["tests", "-s"], "python.testing.pytestArgs": ["tests", "-s"],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:poetry",
"packageManager": "ms-python.python:poetry",
},
],
"files.associations": {
"*.html": "jinja-html",
},
"files.exclude": { "files.exclude": {
"**/__pycache__": true "**/__pycache__": true,
}, },
"terminal.integrated.env.linux": { "terminal.integrated.env.linux": {
"PYTHONPATH": "${workspaceFolder}" "PYTHONPATH": "${workspaceFolder}",
} },
}, },
"launch": { "launch": {
"version": "0.2.1", "version": "0.2.1",
@@ -27,9 +37,9 @@
"gallery.main:app", "gallery.main:app",
"--reload", "--reload",
"--log-config", "--log-config",
"gallery/logging.yaml" "gallery/logging.yaml",
] ],
} },
] ],
} },
} }

View File

@@ -2,32 +2,22 @@ import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.bundle import ApiBundle
from gallery.sketch.weather.api import WeatherApi
from .route import doc from .route import api, doc, view
from .route.api import schedule as schedule_api_route
from .route.api import weather as weather_api_route DEFAULT_LOCALE = "ru_RU.UTF-8"
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
def build_app( def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale) _locale.setlocale(_locale.LC_TIME, locale)
app = FastAPI( app = FastAPI(
title="Gallery", title="Gallery",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
app.state.weather_api = weather_api app.state.api = api_bundle
app.state.schedule_api = schedule_api
doc.mount(app) doc.mount(app)
weather_api_route.mount(app) api.mount(app)
schedule_api_route.mount(app) view.mount(app)
common_view_route.mount(app)
weather_view_route.mount(app)
schedule_view_route.mount(app)
return app return app

15
gallery/easel/core.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,8 @@
from fastapi import FastAPI
from . import schedule, weather
def mount(app: FastAPI):
weather.mount(app)
schedule.mount(app)

View File

@@ -1,27 +1,27 @@
import datetime 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 from gallery.sketch.weather.model import WeatherResponse
def mount(app: FastAPI): def mount(app: FastAPI):
@app.get("/api/weather/locations") @app.get("/api/weather/locations")
async def get_api_weather_locations(request: Request) -> list[str]: async def get_api_weather_locations(request: AppRequest) -> list[str]:
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
return await weather_api.get_locations() return await weather_api.get_locations()
@app.get("/api/weather/{location}/day/{date}") @app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day( async def get_api_weather_day(
request: Request, location: str, date: datetime.date request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse: ) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
return await weather_api.get_day(location, date) return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}") @app.get("/api/weather/{location}/days/{days}")
async def get_api_weather_days( async def get_api_weather_days(
request: Request, location: str, days: int request: AppRequest, location: str, days: int
) -> WeatherResponse: ) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days) return await weather_api.get_days(location, days)

View File

@@ -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)

View File

@@ -29,7 +29,7 @@ def mount(app: FastAPI):
async def get_section_list(request: Request): async def get_section_list(request: Request):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="root_index.html",
context={ context={
"version": __version__, "version": __version__,
"sections": SECTIONS, "sections": SECTIONS,

View File

@@ -47,25 +47,32 @@ app
*/ */
.app-container { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: stretch;
}
.app-menu {
display: flex;
flex-direction: column;
margin: 0.5rem;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
} }
.app-header { .app-header {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} gap: 0.5rem;
.app-title {
display: flex;
justify-content: center; justify-content: center;
flex-grow: 1;
} }
.app-link-home > * { .app-link-home > * {
margin-left: 2rem;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
background-image: url("/static/common/gallery.png"); background-image: url("/static/common/gallery.png");
@@ -81,6 +88,7 @@ app
ul.app-list { ul.app-list {
list-style: none; list-style: none;
padding-left: 0;
} }
ul.app-list > li { ul.app-list > li {

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% block 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>{% block title %}{% endblock %}</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="icon"
href="/static/common/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
</head>
<body class="app-container">
<div class="app-menu">
<a class="app-link-home"
href="/">
<div></div>
</a>
</div>
<div class="app-content">
<h3 class="app-header">
{% block header %}{% endblock %}</span>
</h3>
{% block content %}{% endblock %}
<div class="app-footer">
{% block footer %}{% endblock %}
</div>
</div>
</body>
</html>

View File

@@ -1,41 +0,0 @@
<!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/common/style.css?v={{version}}">
<link rel="icon"
href="/static/common/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
<span>Информация</span>
</div>
</h3>
<ul class="app-list">
{% for section in sections %}
<li>
<a href="{{section.link}}">
<span class="icon"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
<span>{{section.title}}</span>
</a>
</li>
{% endfor %}
</ul>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Информация{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block header %}Информация{% endblock %}
{% block content %}
<ul class="app-list">
{% for section in sections %}
<li>
<a href="{{section.link}}">
<span class="icon"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
<span>{{section.title}}</span>
</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -1,12 +1,12 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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.sketch.schedule.catalog import BUNDLE
from gallery.version import __version__ from gallery.version import __version__
@@ -17,12 +17,17 @@ from .filters import timedelta_format
def mount(app: FastAPI): def mount(app: FastAPI):
base_dir = Path(__file__).parent base_dir = Path(__file__).parent
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static")) app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates") templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.filters["timedelta_format"] = timedelta_format templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse) @app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: Request): async def get_schedule_list(request: AppRequest):
schedule_api: ScheduleApi = request.app.state.schedule_api schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels() channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels) channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -35,9 +40,9 @@ def mount(app: FastAPI):
) )
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse) @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) 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() channels = await schedule_api.get_channels()
responses = [ responses = [
await schedule_api.get_channel_schedule(channel, tag_value.date) await schedule_api.get_channel_schedule(channel, tag_value.date)
@@ -62,9 +67,9 @@ def mount(app: FastAPI):
return RedirectResponse(f"{channel}/tag/today") return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse) @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) 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: if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) response = await schedule_api.get_channel_schedule(channel, tag_value.date)
else: else:

View File

@@ -1,57 +1,43 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
<head> {% block header %}
<meta charset="UTF-8"> <a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
<meta name="viewport" href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
content="width=device-width, initial-scale=1.0"> <a class="button"
<meta http-equiv="X-UA-Compatible" href="../..">⬆️</a>
content="ie=edge"> <span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<title>Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</title> <a class="button"
<link rel="stylesheet" href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
href="/static/common/style.css?v={{version}}"> {% endblock %}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container"> {% block content %}
<h3 class="app-header"> <table>
<a class="app-link-home" <thead>
href="/"> <tr>
<div></div> <td></td>
</a> <td></td>
<div class="app-title"> <td></td>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" </tr>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> </thead>
<a class="button" <tbody>
href="../..">⬆️</a> {% for value in response.values %}
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span> <tr class="{{'live' if value.live else ''}}">
<a class="button" <td>{{value.start.strftime('%H:%M')}}</td>
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a> <td>{{(value.end - value.start) | timedelta_format}}</td>
</div> <td>{{value.label}}</td>
</h3> </tr>
{% endfor %}
<table> </tbody>
<thead> </table>
<tr> {% endblock %}
<td></td>
<td></td>
<td></td>
</tr>
</thead>
<tbody>
{% for value in response.values %}
<tr class="{{'live' if value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -1,38 +1,21 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}ТВ{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
<head> {% block header %}Телепрограмма{% endblock %}
<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/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container"> {% block content %}
<h3 class="app-header"> <ul class="app-list">
<a class="app-link-home" <li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
href="/"> {% for channel in channels %}
<div></div> <li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
</a> {% endfor %}
<div class="app-title"> </ul>
<span>Телепрограмма</span> {% endblock %}
</div>
</h3>
<ul class="app-list">
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
{% for channel in channels %}
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
{% endfor %}
</ul>
</body>
</html>

View File

@@ -1,69 +1,57 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
<head> {% block header %}
<meta charset="UTF-8"> <a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
<meta name="viewport" href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
content="width=device-width, initial-scale=1.0"> <a class="button"
<meta http-equiv="X-UA-Compatible" href="..">⬆️</a>
content="ie=edge"> <span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<title>ТВ</title> <a class="button"
<link rel="stylesheet" href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
href="/static/common/style.css?v={{version}}"> {% endblock %}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container"> {% block content %}
<h3 class="app-header"> <div>
<a class="app-link-home" <table class="{{'live' if live else ''}}">
href="/"> <thead>
<div></div> <tr>
</a> <td></td>
<div class="app-title"> <td></td>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <td></td>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> </tr>
<a class="button" </thead>
href="..">⬆️</a> <tbody>
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span> {% for response in responses %}
<a class="button" {% set values = (response.values|selectattr('live') if live else response.values)|list %}
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a> {% if values|length > 0 %}
</div> <tr>
</h3> <td colspan="3">
<div class="title">{{response.channel.name}}</div>
<table class="{{'live' if live else ''}}"> </td>
<thead> <td></td>
<tr> <td></td>
<td></td> </tr>
<td></td> {% for value in values %}
<td></td> <tr class="{{'live' if not live and value.live else ''}}">
</tr> <td>{{value.start.strftime('%H:%M')}}</td>
</thead> <td>{{(value.end - value.start) | timedelta_format}}</td>
<tbody> <td>{{value.label}}</td>
{% for response in responses %} </tr>
{% set values = (response.values|selectattr('live') if live else response.values)|list %} {% endfor %}
{% if values|length > 0 %} {% endif %}
<tr> {% endfor %}
<td colspan="3"> </tbody>
<div class="title">{{response.channel.name}}</div> </table>
</td> </div>
<td></td> {% endblock %}
<td></td>
</tr>
{% for value in values %}
<tr class="{{'live' if not live and value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -1,12 +1,12 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
@@ -19,11 +19,16 @@ from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI): def mount(app: FastAPI):
base_dir = Path(__file__).parent base_dir = Path(__file__).parent
app.mount("/static/weather", StaticFiles(directory=base_dir / "static")) app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates") templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.filters["wind_direction_icon"] = wind_direction_icon templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_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( return templates.TemplateResponse(
request=request, request=request,
name="weather.html", name="weather.html",
@@ -36,8 +41,8 @@ def mount(app: FastAPI):
) )
@app.get("/weather", response_class=HTMLResponse) @app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: Request): async def get_weather_list(request: AppRequest):
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
locations = await weather_api.get_locations() locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations) locations_data = BUNDLE.select_items(locations)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -54,31 +59,31 @@ def mount(app: FastAPI):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse) @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") response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse) @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") response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse) @app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: Request, location: str, date: datetime.date): async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
response = await weather_api.get_day(location, date) response = await weather_api.get_day(location, date)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse) @app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: Request, location: str, days: int): async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse) @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) 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: if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date) response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS: elif tag_value.type == TagType.DAYS:

View File

@@ -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 { return {
WindDirection.N: "⬇️", WindDirection.N: "⬇️",
WindDirection.NO: "↙️", WindDirection.NE: "↙️",
WindDirection.O: "⬅️", WindDirection.E: "⬅️",
WindDirection.SO: "↖️", WindDirection.SE: "↖️",
WindDirection.S: "⬆️", WindDirection.S: "⬆️",
WindDirection.SW: "↗️", WindDirection.SW: "↗️",
WindDirection.W: "➡️", WindDirection.W: "➡️",
@@ -31,6 +38,8 @@ def cloudness_icon(sky: Sky) -> list[str]:
Cloudness.CLOUDY: "", Cloudness.CLOUDY: "",
Cloudness.MAINLY_CLOUDY: "☁️", Cloudness.MAINLY_CLOUDY: "☁️",
}[sky.cloudness] }[sky.cloudness]
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
main_icon = "🌨️"
else: else:
main_icon = "🌧️" main_icon = "🌧️"
icons = [main_icon] icons = [main_icon]

View File

@@ -1,37 +1,20 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Погода{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
<head> {% block header %}Погода{% endblock %}
<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/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container"> {% block content %}
<h3 class="app-header"> <ul class="app-list">
<a class="app-link-home" {% for location in locations %}
href="/"> <li><a href="weather/{{location.id}}">{{location.name}}</a></li>
<div></div> {% endfor %}
</a> </ul>
<div class="app-title"> {% endblock %}
<span>Погода</span>
</div>
</h3>
<ul class="app-list">
{% for location in locations %}
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
{% endfor %}
</ul>
</body>
</html>

View File

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

View File

@@ -6,12 +6,19 @@ import uvicorn
from gallery.easel import build_app from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi 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.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi from gallery.sketch.weather.cached import CachedWeatherApi
weather_api = CachedWeatherApi(GismeteoApi()) api = ApiBundle(
schedule_api = CachedScheduleApi(MatchTvApi()) [
app = build_app(weather_api, schedule_api) CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
]
)
app = build_app(api)
def run(): def run():

View File

@@ -5,7 +5,13 @@ from typing import Iterable
import dateparser import dateparser
from bs4 import Tag 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 from .core import BaseWidgetParser, RowParser
@@ -61,8 +67,17 @@ class SkyParser(RowParser[Sky]):
PRECIPITATION_MAP: dict[str, Precipitation] = { PRECIPITATION_MAP: dict[str, Precipitation] = {
"без осадков": Precipitation.NO, "без осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN, "небольшой дождь": Precipitation.SMALL_RAIN,
"сильный дождь": Precipitation.HEAVY_RAIN,
"дождь": Precipitation.RAIN, "дождь": Precipitation.RAIN,
"ливень": Precipitation.SHOWER, "ливень": Precipitation.SHOWER,
"снег": Precipitation.SNOW,
"небольшой снег": Precipitation.SNOW,
"сильный снег": Precipitation.HEAVY_SNOW,
"мокрый снег": Precipitation.SNOW,
"снег с дождём": Precipitation.SNOW,
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
"небольшой снег с дождём": Precipitation.SNOW,
"небольшой мокрый снег": Precipitation.SNOW,
} }
def parse_row(self, tag: Tag) -> Iterable[Sky]: def parse_row(self, tag: Tag) -> Iterable[Sky]:
@@ -106,7 +121,7 @@ class WindSpeedParser(RowParser[int]):
def parse_row(self, tag: Tag) -> Iterable[int]: def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select( for item in tag.select(
".widget-row[data-row=wind-speed] > .row-item > speed-value" ".widget-row-wind > .row-item > .wind-speed > speed-value"
): ):
yield int(item.attrs["value"]) yield int(item.attrs["value"])
@@ -115,7 +130,7 @@ class WindGustParser(RowParser[int]):
KEY = "wind_gust" KEY = "wind_gust"
def parse_row(self, tag: Tag) -> Iterable[int]: def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row[data-row=wind-gust] > .row-item"): for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
value = item.select_one("speed-value") value = item.select_one("speed-value")
yield int(value.attrs["value"]) if value else 0 yield int(value.attrs["value"]) if value else 0
@@ -124,26 +139,29 @@ class WindDirectionParser(RowParser[WindDirection]):
KEY = "wind_direction" KEY = "wind_direction"
WIND_DIRECTION_MAP: dict[str, WindDirection] = { WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"": WindDirection.CALM,
"штиль": WindDirection.CALM, "штиль": WindDirection.CALM,
"с": WindDirection.N, "с": WindDirection.N,
"св": WindDirection.NO, "св": WindDirection.NE,
"в": WindDirection.O, "в": WindDirection.E,
"юв": WindDirection.SO, "юв": WindDirection.SE,
"ю": WindDirection.S, "ю": WindDirection.S,
"юз": WindDirection.SW, "юз": WindDirection.SW,
"з": WindDirection.W, "з": WindDirection.W,
"сз": WindDirection.NW, "сз": WindDirection.NW,
} }
def parse_row(self, tag: Tag) -> Iterable[WindDirection]: def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select( for item in tag.select(
".widget-row[data-row=wind-direction] > .row-item > .direction" ".widget-row-wind > .row-item > .wind-speed > .wind-direction"
): ):
wind_direction_str = item.text.lower() wind_direction_str = item.text.lower().strip()
yield self.WIND_DIRECTION_MAP[wind_direction_str] yield WindDirectionDeg.from_direction(
self.WIND_DIRECTION_MAP[wind_direction_str]
).value
class WindPrecipitationParser(RowParser[float]): class PrecipitationParser(RowParser[float]):
KEY = "precipitation" KEY = "precipitation"
def parse_row(self, tag: Tag) -> Iterable[float]: def parse_row(self, tag: Tag) -> Iterable[float]:
@@ -167,7 +185,9 @@ class HumidityParser(RowParser[int]):
KEY = "humidity" KEY = "humidity"
def parse_row(self, tag: Tag) -> Iterable[int]: def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row[data-row=humidity] > .row-item"): for item in tag.select(
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
):
yield int(item.text) yield int(item.text)
@@ -178,7 +198,7 @@ ROW_PARSERS: list[RowParser] = [
WindSpeedParser(), WindSpeedParser(),
WindGustParser(), WindGustParser(),
WindDirectionParser(), WindDirectionParser(),
WindPrecipitationParser(), PrecipitationParser(),
PressureParser(), PressureParser(),
HumidityParser(), HumidityParser(),
] ]

View File

@@ -29,19 +29,25 @@ class MatchTvApi(ScheduleApi):
async def get_channel_schedule( async def get_channel_schedule(
self, channel_id: str, date: datetime.date self, channel_id: str, date: datetime.date
) -> Schedule: ) -> Schedule:
endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}" endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
data = await self.SOURCE.request(endpoint) data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
values = [] values = []
channel_name = soup.select_one(".caption__heading").text.split("|")[0].strip() channel_name = (
soup.select_one(".p-tv-guide-header__title")
.text.replace("Телепрограмма ", "")
.strip()
)
current_day = datetime.datetime.combine( current_day = datetime.datetime.combine(
date.today(), datetime.datetime.min.time() date.today(), datetime.datetime.min.time()
) )
end = current_day + datetime.timedelta(days=1, hours=6) end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None prev_value: ScheduleValue | None = None
for item in soup.select(".teleprogram-schedule .teleprogram-schedule__item"): for item in soup.select(
title = item.select_one(".teleprogram-item__title").text.strip() ".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
time_str = item.select_one(".teleprogram-item__time").text.strip() ):
title = item.select_one(".p-tv-guide-schedule-channel-transmission__title").text.strip()
time_str = item.select_one(".p-tv-guide-schedule-channel-transmission__time-block").text.strip()
hours, minutes = map(int, time_str.split(":")) hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes) item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour: if prev_value is not None and item_date.hour < prev_value.start.hour:

View File

View File

@@ -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)),
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()

View File

@@ -1,6 +1,12 @@
from typing import TypeVar
class Api: class Api:
PROVIDER: str PROVIDER: str
@property @property
def provider(self) -> str: def provider(self) -> str:
return self.PROVIDER return self.PROVIDER
API = TypeVar("API", bound=Api)

30
gallery/sketch/bundle.py Normal file
View File

@@ -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)

View File

@@ -1,10 +1,8 @@
from typing import Generic, TypeVar from typing import Generic
from gallery.util import TimeUnit from gallery.util import TimeUnit
from .api import Api from .api import API, Api
API = TypeVar("API", bound=Api)
class CachedApi(Api, Generic[API]): class CachedApi(Api, Generic[API]):

View File

@@ -7,5 +7,8 @@ class CatalogBundle(Generic[T]):
def __init__(self, items: list[T]) -> None: def __init__(self, items: list[T]) -> None:
self._items_by_id = {item.id: item for item in items} 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]: def select_items(self, ids: list[str]) -> list[T]:
return [self._items_by_id[id_] for id_ in ids] return [self._items_by_id[id_] for id_ in ids]

View File

@@ -6,9 +6,12 @@ class MockData:
def __init__(self, data_dir) -> None: def __init__(self, data_dir) -> None:
self._data_dir = data_dir 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: 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: 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 return data

View File

@@ -15,7 +15,17 @@ class LocationId(str, Enum):
BUNDLE = CatalogBundle( BUNDLE = CatalogBundle(
[ [
Location(id=LocationId.OREL, name="Орёл"), Location(
Location(id=LocationId.ZMIYEVKA, name="Змиёвка"), id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
] ]
) )

View File

@@ -12,6 +12,8 @@ class Model(BaseModel):
class Location(Model): class Location(Model):
id: str id: str
name: str name: str
lat: float
lon: float
class Cloudness(str, Enum): class Cloudness(str, Enum):
@@ -25,7 +27,10 @@ class Precipitation(str, Enum):
NO = "no" NO = "no"
SMALL_RAIN = "small_rain" SMALL_RAIN = "small_rain"
RAIN = "rain" RAIN = "rain"
HEAVY_RAIN = "heavy_rain"
SHOWER = "shower" SHOWER = "shower"
SNOW = "snow"
HEAVY_SNOW = "heavy_snow"
class Sky(Model): class Sky(Model):
@@ -38,22 +43,69 @@ class Sky(Model):
class WindDirection(str, Enum): class WindDirection(str, Enum):
CALM = "calm" CALM = "calm"
N = "N" N = "N"
NO = "NO" NE = "NE"
O = "O" E = "E"
SO = "SO" SE = "SE"
S = "S" S = "S"
SW = "SW" SW = "SW"
W = "W" W = "W"
NW = "NW" 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): class WeatherValue(Model):
date: datetime.datetime date: datetime.datetime
sky: Sky sky: Sky
temperature: list[int] temperature: list[int]
wind_speed: int wind_speed: int
wind_gust: int wind_gust: int
wind_direction: WindDirection wind_direction: float
precipitation: float precipitation: float
pressure: list[int] pressure: list[int]
humidity: int humidity: int

View File

@@ -1,6 +1,7 @@
import datetime 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: def build_weather_value(date: datetime.datetime) -> WeatherValue:
@@ -15,8 +16,49 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
temperature=[], temperature=[],
wind_speed=0, wind_speed=0,
wind_gust=0, wind_gust=0,
wind_direction=WindDirection.CALM, wind_direction=WindDirectionDeg(-1),
precipitation=0, precipitation=0,
pressure=[], pressure=[],
humidity=0, 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

View File

@@ -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