2 Commits

Author SHA1 Message Date
657a62eab5 ci(version): 0.3.4 2026-06-24 15:54:43 +03:00
d8e2be013a fix(gismeteo): fix precipitation parser 2026-06-24 15:54:35 +03:00
16 changed files with 63 additions and 130 deletions

View File

@@ -8,11 +8,6 @@ from gallery.sketch.weather.model import Location, WeatherResponse
router = APIRouter(prefix="/weather") router = APIRouter(prefix="/weather")
@router.get("/providers")
async def get_api_weather_providers(request: AppRequest) -> list[str]:
return request.app.state.api.get_api_providers("weather")
@router.get("/locations") @router.get("/locations")
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]: async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]:
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather

View File

@@ -1,19 +1,15 @@
import datetime import datetime
import typing
from pathlib import Path from pathlib import Path
from babel.dates import format_date from babel.dates import format_date
from fastapi import Request from fastapi import Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest
from gallery.version import __version__ from gallery.version import __version__
from ...translation import _ from ...translation import _
from .tag import TagUtil from .tag import TagUtil
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
def is_widget(request: Request) -> bool: def is_widget(request: Request) -> bool:
return (request.url.hostname and request.url.hostname.startswith("weather")) or ( return (request.url.hostname and request.url.hostname.startswith("weather")) or (
@@ -21,23 +17,17 @@ def is_widget(request: Request) -> bool:
) )
def base_context_processor(request: Request) -> dict: def context_processor(request: Request) -> dict:
return { return {
"is_widget": is_widget(request), "is_widget": is_widget(request),
"provider": request.query_params.get("provider"),
} }
def build_templates( def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates:
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
) -> Jinja2Templates:
directory = [Path(__file__).parent.parent / "templates"] directory = [Path(__file__).parent.parent / "templates"]
if templates_dir: if templates_dir:
directory.append(templates_dir) directory.append(templates_dir)
context_processors: list[ContextProcessor] = [base_context_processor] templates = Jinja2Templates(directory=directory, context_processors=[context_processor])
if context_processor:
context_processors.append(context_processor)
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
templates.env.globals.update( templates.env.globals.update(
{ {
"_": _, "_": _,

View File

@@ -1,32 +1,16 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import BUNDLE from gallery.sketch.schedule.catalog import BUNDLE
from ..common.utils.tag import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates from ..common.utils.template import build_templates
from .filters import timedelta_format from .filters import timedelta_format
async def get_schedule_api(request: AppRequest, provider: str | None = None) -> ScheduleApi:
return request.app.state.api.get_schedule(provider)
ScheduleApiDepends = Annotated[ScheduleApi, Depends(get_schedule_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi.TYPE),
}
templates = build_templates( templates = build_templates(
Path(__file__).parent / "templates", Path(__file__).parent / "templates",
{ {
@@ -34,11 +18,12 @@ templates = build_templates(
}, },
) )
router = APIRouter(prefix="/schedule") router = APIRouter()
@router.get("/", response_class=HTMLResponse) @router.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest, schedule_api: ScheduleApiDepends): async def get_schedule_list(request: AppRequest):
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(
@@ -50,9 +35,10 @@ async def get_schedule_list(request: AppRequest, schedule_api: ScheduleApiDepend
) )
@router.get("/tag/{tag}", response_class=HTMLResponse) @router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, schedule_api: ScheduleApiDepends, 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 = request.app.state.api.schedule
results = await schedule_api.get_all_schedules(tag_value.date) results = await schedule_api.get_all_schedules(tag_value.date)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@@ -67,14 +53,15 @@ async def get_schedule_tag(request: AppRequest, schedule_api: ScheduleApiDepends
) )
@router.get("/{channel}", response_class=RedirectResponse) @router.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str): async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today") return RedirectResponse(f"{channel}/tag/today")
@router.get("/{channel}/tag/{tag}", response_class=HTMLResponse) @router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, 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 = 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

@@ -4,12 +4,12 @@
{% block content %} {% block content %}
<h1>{{_("TV program")}}</h1> <h1>{{_("TV program")}}</h1>
<div class="list-group mb-5"> <div class="list-group mb-5">
<a href="tag/today" <a href="schedule/tag/today"
class="list-group-item list-group-item-action px-4"> class="list-group-item list-group-item-action px-4">
<span class="fw-bold">Все</span> <span class="fw-bold">Все</span>
</a> </a>
{% for channel in channels %} {% for channel in channels %}
<a href="{{channel.id}}" <a href="schedule/{{channel.id}}"
class="list-group-item list-group-item-action px-4"> class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span> <span class="text-primary">{{channel.name}}</span>
</a> </a>

View File

@@ -1,39 +1,22 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.tag import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates from ..common.utils.template import build_templates
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
async def get_weather_api(request: AppRequest, provider: str | None = None) -> WeatherApi:
return request.app.state.api.get_weather(provider)
WeatherApiDepends = Annotated[WeatherApi, Depends(get_weather_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi.TYPE),
}
templates = build_templates( templates = build_templates(
Path(__file__).parent / "templates", Path(__file__).parent / "templates",
{ {
"wind_direction_icon": wind_direction_icon, "wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon, "cloudness_icon": cloudness_icon,
}, },
context_procesor,
) )
@@ -47,11 +30,12 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
) )
router = APIRouter(prefix="/weather") router = APIRouter()
@router.get("/", response_class=HTMLResponse) @router.get("/weather", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None): async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather
locations = (await weather_api.find_locations(query)) if query else [] locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@@ -62,31 +46,29 @@ async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends,
) )
@router.get("/{location}", response_class=RedirectResponse) @router.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@router.get("/{location}/day/{date}", response_class=HTMLResponse) @router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day( async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
request: AppRequest, weather_api = request.app.state.api.weather
weather_api: WeatherApiDepends,
location: str,
date: datetime.date,
):
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)
@router.get("/{location}/days/{days}", response_class=HTMLResponse) @router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int): async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@router.get("/{location}/tag/{tag}", response_class=HTMLResponse) @router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, 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 = 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,23 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{_("Weather")}}{% endblock %} {% block title %}{{_("Weather")}}{% endblock %}
{# {% block header %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{{provider or providers[0]}}
</button>
<ul class="dropdown-menu">
{% for provider in providers %}
<li><a class="dropdown-item"
href="{{ url_for('get_weather_index').include_query_params(provider=provider) }}">{{provider}}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %} #}
{% block content %} {% block content %}
<h1>{{_("Weather")}}</h1> <h1>{{_("Weather")}}</h1>
<form action="" <form action=""
@@ -36,7 +19,7 @@
<ul id="locations" <ul id="locations"
class="list-group mb-5"> class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<a href="{{location.id}}" <a href="weather/{{location.id}}"
class="list-group-item list-group-item-action px-4" class="list-group-item list-group-item-action px-4"
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});"> onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
<span class="fi fi-{{location.country_code}} me-1"></span> <span class="fi fi-{{location.country_code}} me-1"></span>
@@ -56,7 +39,7 @@
container.innerHTML = ''; container.innerHTML = '';
for (const [id, name] of Object.entries(locations)) { for (const [id, name] of Object.entries(locations)) {
const element = document.createElement('a'); const element = document.createElement('a');
element.href = `${id}`; element.href = `weather/${id}`;
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start'; element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
element.innerHTML = ` element.innerHTML = `
<span class="text-primary me-auto">${name}</span> <span class="text-primary me-auto">${name}</span>

View File

@@ -170,8 +170,12 @@ class PrecipitationParser(RowParser[float]):
KEY = "precipitation" KEY = "precipitation"
def parse_row(self, tag: Tag) -> Iterable[float]: def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"): for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
yield float(item.text.replace(",", ".").replace("< ", "")) value = item.select_one("precipitation-value")
if value:
yield float(value.attrs["value"])
else:
yield 0
class PressureParser(RowParser[list[int]]): class PressureParser(RowParser[list[int]]):

View File

@@ -2,13 +2,8 @@ from typing import TypeVar
class Api: class Api:
TYPE: str
PROVIDER: str PROVIDER: str
@property
def type(self) -> str:
return self.TYPE
@property @property
def provider(self) -> str: def provider(self) -> str:
return self.PROVIDER return self.PROVIDER

View File

@@ -3,27 +3,26 @@ from .schedule.api import ScheduleApi
from .weather.api import WeatherApi from .weather.api import WeatherApi
class ApiBundle: class ApiBundle(list[Api]):
def __init__(self, values: list[Api]): def __init__(self, values: list[Api]) -> None:
self._values = values super().__init__(values)
self._by_provider = {value.provider: value for value in values}
def get_api_providers(self, api_type: str) -> list[str]: def get_api_by_provider(self, provider: str) -> Api:
result = [] for value in self:
for value in self._values: if value.PROVIDER == provider:
if value.type == api_type: return value
result.append(value.provider) raise ValueError(provider)
return result
def get_api(self, api_type: type[API], provider: str | None = None) -> API: def get_api_by_type(self, api_type: type[API]) -> API:
for value in self._values: for value in self:
if isinstance(value, api_type): if isinstance(value, api_type):
if provider is None or provider == value.provider: return value
return value
raise ValueError(api_type) raise ValueError(api_type)
def get_weather(self, provider: str | None = None) -> WeatherApi: @property
return self.get_api(WeatherApi, provider) def weather(self) -> WeatherApi:
return self.get_api_by_type(WeatherApi)
def get_schedule(self, provider: str | None = None) -> ScheduleApi: @property
return self.get_api(ScheduleApi, provider) def schedule(self) -> ScheduleApi:
return self.get_api_by_type(ScheduleApi)

View File

@@ -6,7 +6,6 @@ from .model import ChannelId, Schedule
class ScheduleApi(Api): class ScheduleApi(Api):
TYPE = "schedule"
INTERVAL: float = 0.5 INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]: async def get_channels(self) -> list[ChannelId]:

View File

@@ -12,7 +12,7 @@ CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]): class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
CACHE_KEY = ScheduleApi.TYPE CACHE_KEY = "schedule"
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels", key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",

View File

@@ -5,7 +5,6 @@ from .model import Location, WeatherResponse
class WeatherApi(Api): class WeatherApi(Api):
TYPE = "weather"
async def find_locations(self, query: str) -> list[Location]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError raise NotImplementedError

View File

@@ -11,7 +11,7 @@ CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]): class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = WeatherApi.TYPE CACHE_KEY = "weather"
@cached( @cached(
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "gallery" name = "gallery"
version = "0.3.3" version = "0.3.4"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"

View File

@@ -1,12 +1,12 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.3.3", "version": "0.3.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.3.3", "version": "0.3.4",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.3.3", "version": "0.3.4",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite build --watch" "dev": "vite build --watch"