Compare commits

..
12 Commits
115 changed files with 1499 additions and 6399 deletions
+1 -1
View File
@@ -10,4 +10,4 @@ max_line_length = 120
[*.md]
max_line_length = off
trim_trailing_whitespace = true
trim_trailing_whitespace = false
+5 -4
View File
@@ -1,7 +1,8 @@
DOCKER_REPO=git.shmyga.ru
DOCKER_GROUP=infernalgames
DOCKER_ROOT="${DOCKER_REPO}/${DOCKER_GROUP}"
DOCKER_TAG="${DOCKER_TAG:-latest}"
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
DOCKER_PROJECTS=("gallery")
OPENWEATHER_KEY=""
GISMETEO_AUTH=""
OPENWEATHER_KEY="<EMPTY>"
-42
View File
@@ -1,42 +0,0 @@
name: build
on:
push:
tags: ["*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
# repo
- name: Check out repository
uses: actions/checkout@v7
# python
- name: Set up python
id: setup-python
uses: actions/setup-python@v7
with:
python-version: "3.14"
cache: "pip"
# project-manager
- name: Install Project Manager
run: |
pip install project-manager \
--index-url https://git.shmyga.ru/api/packages/InfernalGames/pypi/simple/ \
--extra-index-url https://pypi.org/simple
# setup
- name: Setup config
run: pm setup:config -vv
# build
- name: Build docker
run: pm build:docker -vv
# login docker
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: git.shmyga.ru
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# pubish
- name: Publish docker
run: pm publish:docker -vv
-50
View File
@@ -1,50 +0,0 @@
name: test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
# repo
- name: Check out repository
uses: actions/checkout@v7
# python
- name: Set up python
id: setup-python
uses: actions/setup-python@v7
with:
python-version: "3.14"
# poetry
- name: Load cached Poetry installation
id: cached-poetry
uses: actions/cache@v5
with:
path: ~/.local
key: poetry-1 # increment to reset cache
- name: Install Poetry
if: steps.cached-poetry.outputs.cache-hit != 'true'
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
virtualenvs-path: .venv
installer-parallel: true
# venv
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v5
with:
path: .venv
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: ~/.local/bin/poetry install --no-interaction --no-root
- name: Install project
run: ~/.local/bin/poetry install --no-interaction
# test
- name: Run tests
run: |
source .venv/bin/activate
coverage run -m pytest tests/
coverage report
-7
View File
@@ -1,7 +0,0 @@
modules = [".", "static"]
[presets.locales.tasks.build.locales]
priority = 1
conditions = ["{{ 'locales/ru/LC_MESSAGES' | file }}"]
cwd = "locales/ru/LC_MESSAGES"
commands = ["msgfmt messages.po"]
+14 -3
View File
@@ -4,12 +4,23 @@ ignore=.venv
[MESSAGES CONTROL]
disable=
missing-docstring,
fixme,
invalid-name,
duplicate-code,
too-few-public-methods,
too-many-instance-attributes,
too-many-arguments,
too-many-locals,
too-many-boolean-expressions,
too-many-public-methods,
no-else-return,
singleton-comparison,
unused-argument,
unspecified-encoding,
fixme,
duplicate-code,
extension-pkg-allow-list=
lxml,
GeoIP,
pydantic
[MISCELLANEOUS]
notes=FIXME,TODO
+3 -13
View File
@@ -9,7 +9,7 @@ COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
poetry install --extras app --no-root
poetry install --with app --no-root
COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
@@ -23,23 +23,13 @@ COPY static ./
RUN npm run build
FROM python:3.14-slim
ENV PLAYWRIGHT_BROWSERS_PATH="/opt/playwright-browsers"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
pip install playwright && \
playwright install --with-deps chromium
ENV PATH="/app/.venv/bin:$PATH"
ENV TZ="Europe/Moscow"
WORKDIR /app
ENV TZ="Europe/Moscow"
COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/
# COPY --from=builder --parents locales/**/*.mo ./
COPY --from=builder /app/locales ./locales
COPY --from=builder --parents locales/**/*.mo ./
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
+6 -68
View File
@@ -1,75 +1,13 @@
# API Gallery
Currently implemented APIs:
Weather and TV program API
- Weather forecast
- TV program
![API Gallery](docs/screenshot.png "API Gallery")
## API
Swagger: https://api.shmyga.ru/docs
### Weather
#### Providers list
```
https://api.shmyga.ru/api/weather/providers
```
#### Locations search
```
https://api.shmyga.ru/api/weather/{provider}/locations?query={query}
```
#### Weather forecast
```
https://api.shmyga.ru/api/weather/{provider}/{location_id}/day/{date}
https://api.shmyga.ru/api/weather/{provider}/{location_id}/days/{days_count}
```
### TV program
#### Providers list
```
https://api.shmyga.ru/api/schedule/providers
```
#### Channels search
```
https://api.shmyga.ru/api/schedule/{provider}/channels?query={query}
```
#### TV program
```
https://api.shmyga.ru/api/schedule/{provider}/{channel_id}/{date}
```
## Providers
### Weather
| Provider | Site | API sample |
| :- | :- | :- |
| gismeteo | https://www.gismeteo.ru | https://api.shmyga.ru/api/weather/gismeteo/orel-4432/day/today |
| openweather | https://openweathermap.org | https://api.shmyga.ru/api/weather/openweather/52.9685433%3A36.0692477/day/today |
| yandex | https://yandex.ru/pogoda | https://api.shmyga.ru/api/weather/yandex/10/day/today |
| rp5 | https://rp5.ru | https://api.shmyga.ru/api/weather/rp5/Погода_в_Орле%2C_Орловская_область/day/today |
### TV program
| Provider | Site | API sample |
| :- | :- | :- |
| matchtv | https://matchtv.ru/tvguide | https://api.shmyga.ru/api/schedule/matchtv/igra/today |
| yandex | https://tv.yandex.ru | https://api.shmyga.ru/api/schedule/yandex/start-103/today |
## Demo
## View
https://api.shmyga.ru
![API Gallery](docs/screenshot.png "API Gallery")
## Swagger
https://api.shmyga.ru/docs
-1
View File
@@ -13,7 +13,6 @@ services:
environment:
- REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
- GISMETEO_AUTH=$GISMETEO_AUTH
- DEBUG=1
ports:
- 8000:80
+4 -5
View File
@@ -1,19 +1,18 @@
name: gallery-${DOCKER_TAG}
name: gallery
services:
redis:
container_name: gallery-${DOCKER_TAG}-redis
container_name: gallery-redis
image: redis:alpine
stop_grace_period: 3s
volumes:
- redis_data:/data
app:
container_name: gallery-${DOCKER_TAG}-app
image: ${DOCKER_ROOT}/gallery:${DOCKER_TAG}
container_name: gallery-app
image: ${DOCKER_ROOT}/gallery
environment:
- REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
- GISMETEO_AUTH=$GISMETEO_AUTH
depends_on:
- redis
ports:
-1
View File
@@ -9,7 +9,6 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.terminal.useEnvFile": true,
"python.analysis.importFormat": "relative",
"python-envs.pythonProjects": [
{
"path": ".",
+1 -1
View File
@@ -10,7 +10,7 @@ caches.set_config(
{
"default": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
"serializer": {"class": "aiocache.serializers.StringSerializer"},
},
"redis": {
"cache": "aiocache.RedisCache",
+3 -9
View File
@@ -1,11 +1,10 @@
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, view
from .route import api, doc, view
def build_app(api_bundle: ApiBundle) -> FastAPI:
@@ -16,12 +15,7 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
)
app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
api.app.state.api = api_bundle
app.mount("/api", api.app)
doc.mount(app)
app.include_router(api.router)
app.include_router(view.router)
@app.get("/docs")
def get_docs():
return RedirectResponse("/api/docs")
return app
-25
View File
@@ -1,25 +0,0 @@
import datetime
from typing import Annotated
from fastapi import Depends, HTTPException, status
def date_parser(date: str) -> datetime.date:
if date == "today":
return datetime.date.today()
elif date == "tomorrow":
return datetime.date.today() + datetime.timedelta(days=1)
elif date.startswith("day-"):
days = int(date.split("-")[-1])
return datetime.date.today() + datetime.timedelta(days=days)
else:
try:
return datetime.date.strptime(date, "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"date": f"{exc}"},
) from exc
DateDepends = Annotated[datetime.date, Depends(date_parser)]
+1 -33
View File
@@ -1,4 +1,4 @@
from typing import Annotated, NamedTuple
from typing import Annotated
from fastapi import Depends
@@ -7,35 +7,3 @@ from gallery.sketch.schedule.api import ScheduleApi
from .api import api_resolver
ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))]
class ChannelDef(NamedTuple):
provider: str
id: str
GROUP_CANNELS_MAP: dict[str, list[ChannelDef]] = {
"central": [
ChannelDef("yandex", "pervyy-16"),
ChannelDef("yandex", "rossiya-1-31"),
ChannelDef("yandex", "ntv-11"),
ChannelDef("yandex", "sts-8"),
],
"sport": [
ChannelDef("matchtv", "matchtv"),
ChannelDef("matchtv", "igra"),
ChannelDef("matchtv", "arena"),
ChannelDef("matchtv", "futbol-1"),
ChannelDef("matchtv", "futbol-2"),
ChannelDef("matchtv", "futbol-3"),
ChannelDef("matchtv", "strana"),
ChannelDef("yandex", "start-103"),
],
}
def resolve_group_channels(group_id: str) -> list[ChannelDef]:
return GROUP_CANNELS_MAP.get(group_id) or [ChannelDef(*item.split(":")) for item in group_id.split(",")]
ScheduleGroupChannelsDepends = Annotated[list[ChannelDef], Depends(resolve_group_channels)]
+4 -18
View File
@@ -1,21 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from gallery.easel.route import doc
from fastapi import APIRouter
from . import schedule, weather
app = FastAPI(
docs_url=None,
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(weather.router)
app.include_router(schedule.router)
doc.mount(app, "/api")
router = APIRouter(prefix="/api")
router.include_router(weather.router)
router.include_router(schedule.router)
+4 -21
View File
@@ -1,32 +1,15 @@
import datetime
from fastapi import APIRouter
from gallery.easel.core import AppRequest
from gallery.easel.depends.schedule import (
GROUP_CANNELS_MAP,
ScheduleApiDepends,
ScheduleGroupChannelsDepends,
)
from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule
from ...depends.date import DateDepends
router = APIRouter(prefix="/schedule", tags=["Schedule"])
@router.get("/groups")
async def get_api_schedule_groups():
return list(GROUP_CANNELS_MAP.keys())
@router.get("/group/{group_id}/{date}")
async def get_api_schedule_group_schedule(
request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends
) -> list[Schedule]:
api = request.app.state.api
return [await api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date) for channel in channels]
@router.get("/providers")
async def get_api_weather_providers(request: AppRequest) -> list[str]:
return request.app.state.api.get_api_providers(ScheduleApi)
@@ -39,6 +22,6 @@ async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: st
@router.get("/{provider}/{channel}/{date}")
async def get_api_schedule_channel_schedule(
schedule_api: ScheduleApiDepends, channel: str, date: DateDepends
schedule_api: ScheduleApiDepends, channel: str, date: datetime.date
) -> Schedule:
return await schedule_api.get_schedule(channel, date)
+3 -3
View File
@@ -1,3 +1,5 @@
import datetime
from fastapi import APIRouter
from gallery.easel.core import AppRequest
@@ -5,8 +7,6 @@ from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse
from ...depends.date import DateDepends
router = APIRouter(prefix="/weather", tags=["Weather"])
@@ -21,7 +21,7 @@ async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str)
@router.get("/{provider}/{location}/day/{date}")
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: DateDepends) -> WeatherResponse:
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: datetime.date) -> WeatherResponse:
return await weather_api.get_day(location, date)
+3 -4
View File
@@ -8,8 +8,7 @@ from fastapi.openapi.docs import (
from fastapi.staticfiles import StaticFiles
def mount(app: FastAPI, prefix: str | None = None):
openapi_url = prefix + app.openapi_url if prefix else app.openapi_url
def mount(app: FastAPI):
app.mount(
"/docs/static",
StaticFiles(directory=Path(__file__).parent / "static"),
@@ -18,7 +17,7 @@ def mount(app: FastAPI, prefix: str | None = None):
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=openapi_url,
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",
@@ -32,7 +31,7 @@ def mount(app: FastAPI, prefix: str | None = None):
@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url=openapi_url,
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url="docs/static/redoc.standalone.js",
)
@@ -23,6 +23,7 @@
<div class="app col-lg-8 mx-auto p-3 py-md-5">
{% if not is_widget %}
<header class="app-header pb-3 mb-5 border-bottom">
<div>{{request.query_params.widget}}</div>
<div class="link-list">
<app-link href="/"
icon="gear">API Gallery</app-link>
@@ -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)
@@ -1,7 +1,6 @@
import datetime
import typing
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit
from babel.dates import format_date
from fastapi import Request
@@ -11,6 +10,7 @@ from gallery.easel.core import AppRequest
from gallery.version import __version__
from ...translation import _
from .tag import TagUtil
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
@@ -24,30 +24,10 @@ def is_widget(request: Request) -> bool:
def base_context_processor(request: Request) -> dict:
return {
"is_widget": is_widget(request),
"provider": request.query_params.get("provider") or request.path_params.get("provider"),
"provider": request.query_params.get("provider"),
}
def build_url(
request: Request, *, path: list[str] | None = None, up: int | None = None, query: dict | None = None
) -> str:
parts = urlsplit(str(request.url))
if up:
path = [".."] * up
if path:
path_parts = parts.path.split("/")
for item in path:
if item == "..":
path_parts = path_parts[:-1]
else:
path_parts.append(str(item))
parts = parts._replace(path="/".join(path_parts))
if query:
query_dict = {**dict(parse_qsl(parts.query)), **query}
parts = parts._replace(query=urlencode(query_dict))
return "/".join([""] + parts.geturl().split("/")[3:])
def build_templates(
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
) -> Jinja2Templates:
@@ -64,8 +44,8 @@ def build_templates(
"version": __version__,
"format_date": format_date,
"datetime": datetime,
"tag_util": TagUtil,
"DATE_FORMAT": "E, d MMMM Y",
"build_url": build_url,
}
)
if filters:
@@ -17,13 +17,13 @@
</div>
<h1>{{_("Docs")}}</h1>
<div class="list-group mb-3">
<app-link href="/api/docs"
<app-link href="/docs"
target="_blank"
icon="file-earmark-text"
class="list-group-item list-group-item-action">
Swagger UI
</app-link>
<app-link href="/api/redoc"
<app-link href="/redoc"
target="_blank"
icon="file-earmark-text"
class="list-group-item list-group-item-action">
+70 -1
View File
@@ -1 +1,70 @@
from .route import router
import datetime
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import timedelta_format
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"timedelta_format": timedelta_format,
},
context_procesor,
)
router = APIRouter(prefix="/schedule")
@router.get("/", response_class=HTMLResponse)
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
schedule_api = api_resolver(ScheduleApi)(request, provider)
channels = await schedule_api.find_channels(query)
else:
channels = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"channels": channels,
},
)
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today")
@router.get("/{provider}/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
if tag_value.type == TagType.DAY:
response = await schedule_api.get_schedule(channel, tag_value.date)
else:
raise ValueError(tag)
return templates.TemplateResponse(
request=request,
name="channel.html",
context={
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@@ -1,91 +0,0 @@
import datetime
from pathlib import Path
from urllib import parse
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.date import DateDepends
from gallery.easel.depends.schedule import (
GROUP_CANNELS_MAP,
ScheduleApiDepends,
ScheduleGroupChannelsDepends,
)
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Schedule
from ..common.utils.template import build_templates
from .filters import timedelta_format
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"timedelta_format": timedelta_format,
},
context_procesor,
)
router = APIRouter(prefix="/schedule")
@router.get("/", response_class=HTMLResponse)
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
schedule_api = api_resolver(ScheduleApi)(request, provider)
channels = await schedule_api.find_channels(query)
else:
channels = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={"channels": [item.model_dump() for item in channels], "groups": GROUP_CANNELS_MAP.keys()},
)
async def _get_group_schedule(api: ApiBundle, channels: list[tuple[str, str]], date: datetime.date) -> list[Schedule]:
return [
await api.get_api(ScheduleApi, provider).get_schedule(channel_id, date) for provider, channel_id in channels
]
@router.get("/group/{group_id}", response_class=RedirectResponse)
async def get_group_default(group_id: str):
return RedirectResponse(f"{parse.quote(group_id)}/today")
@router.get("/group/{group_id}/{date}", response_class=HTMLResponse)
async def get_group(request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends):
responses = [
await request.app.state.api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date)
for channel in channels
]
return templates.TemplateResponse(
request=request,
name="group.html",
context={"responses": responses},
)
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/today")
@router.get("/{provider}/{channel}/{date}", response_class=HTMLResponse)
async def get_channel(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, date: DateDepends):
response = await schedule_api.get_schedule(channel, date)
return templates.TemplateResponse(
request=request,
name="channel.html",
context={"response": response},
)
@@ -1,7 +1,4 @@
{% extends "base.html" %}
{% set live = request.query_params.live == '1' %}
{% set date = response.date %}
{% block title %}
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}
@@ -13,23 +10,18 @@ locale=request.state.language)}}
{% endblock %}
{% block content %}
<h4 class="mb-4">
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<h4>
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="{{build_url(request, up=3)}}">
href="../../..">
<i class="bi bi-arrow-up-square"></i>
</a>
<a class="icon-link {{'text-secondary' if not live else ''}}"
href="{{build_url(request, query={'live': 0 if live else 1})}}">
<i class="bi bi-cast"></i>
</a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4>
@@ -37,14 +29,15 @@ locale=request.state.language)}}
<thead>
<tr>
<td></td>
<td style="width:100%"></td>
<td></td>
</tr>
</thead>
<tbody>
{% for value in response.values %}
<tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
<tr class="{{'table-success' if value.live else ''}}">
<td>
<span>{{value.start.strftime('%H:%M')}}</span>&nbsp;<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
<span>{{value.start.strftime('%H:%M')}}</span>
<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
</td>
<td>{{value.label}}</td>
</tr>
@@ -1,59 +0,0 @@
{% extends "base.html" %}
{% set live = request.query_params.live == '1' %}
{% set date = responses[0].date %}
{% block title %}
{{_("TV program")}}
{% endblock %}
{% block header %}
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
{% block content %}
<h4 class="mb-4">
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="{{build_url(request, up=3)}}">
<i class="bi bi-arrow-up-square"></i>
</a>
<a class="icon-link {{'text-secondary' if not live else ''}}"
href="{{build_url(request, query={'live': 0 if live else 1})}}">
<i class="bi bi-cast"></i>
</a>
<span>{{format_date(date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4>
{% for response in responses %}
<h5>
<span class="badge rounded-pill text-bg-secondary fs-6">{{response.channel.provider}}</span>
<span>{{response.channel.name}}</span>
</h5>
<table class="table">
<thead>
<tr>
<td></td>
<td style="width:100%"></td>
</tr>
</thead>
<tbody>
{% for value in response.values %}
<tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
<td>
<span>{{value.start.strftime('%H:%M')}}</span>&nbsp;<span class="small ms-1">({{(value.end - value.start) |
timedelta_format}})</span>
</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% endblock %}
@@ -35,26 +35,17 @@
</div>
</form>
{% if channels %}
<div class="mb-4">
<schedule-channel-list value="{{channels | tojson | forceescape}}"
onclick="scheduleChannelManager.store(element)"
onadd="scheduleChannelManager.add(element)"></schedule-channel-list>
<hr>
</div>
{% endif %}
<div class="mb-4">
<h3>{{_("Stored channels")}}</h3>
<schedule-channel-list id="storedChannels"
onremove="scheduleChannelManager.remove(element)"></schedule-channel-list>
</div>
<div class="mb-4">
<h3>{{_("Channel groups")}}</h3>
<ul class="list-group">
{% for group in groups %}
<schedule-channel-group value="{{group | tojson | forceescape}}"></schedule-channel-group>
<ul id="channels"
class="list-group mb-5">
{% for channel in channels %}
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}"></schedule-channel>
{% endfor %}
</ul>
</div>
<hr>
{% endif %}
<ul id="storedChannels"
class="list-group mb-5">
</ul>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
@@ -62,8 +53,9 @@
if (searchQuery) {
document.querySelector('#query').value = searchQuery;
}
document.addEventListener("DOMContentLoaded", (event) => {
scheduleChannelManager.load();
scheduleChannelManager.loadChannels('#storedChannels');
});
})();
</script>
+102 -1
View File
@@ -1 +1,102 @@
from .route import router
import datetime
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
"weather_icon_svg": weather_icon_svg,
},
context_procesor,
)
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"response": response,
},
)
router = APIRouter(prefix="/weather")
@router.get("/", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
weather_api = api_resolver(WeatherApi)(request, provider)
locations = await weather_api.find_locations(query)
else:
locations = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"locations": locations,
},
)
@router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather(location: str):
return RedirectResponse(f"{location}/tag/today")
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(
request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: datetime.date,
):
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
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)
@router.get("/zmiyevka-184640/tag/{tag}", response_class=RedirectResponse)
async def get_zmiyevka_weather_compat(request: AppRequest):
path_parts = request.url.path.split("/")
path_parts.insert(2, "gismeteo")
return RedirectResponse("/".join(path_parts))
+5 -3
View File
@@ -7,15 +7,17 @@ from gallery.sketch.weather.model import (
Precipitation,
Sky,
WindDirection,
WindDirectionDeg,
)
from gallery.util import root_path
def wind_direction_icon(wind_direction: WindDirection | None) -> str:
if wind_direction is None:
def wind_direction_icon(wind_direction_deg: float) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction
if wind_direction == WindDirection.CALM:
return "wind-calm"
else:
return f"wind-from-{wind_direction.lower()}"
return f"wind-from-{wind_direction.name.lower()}"
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
-87
View File
@@ -1,87 +0,0 @@
from pathlib import Path
from typing import Literal
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.date import DateDepends
from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.template import build_templates
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
"weather_icon_svg": weather_icon_svg,
},
context_procesor,
)
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={"response": response},
)
router = APIRouter(prefix="/weather")
@router.get("/", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
if query and provider:
weather_api = api_resolver(WeatherApi)(request, provider)
locations = await weather_api.find_locations(query)
else:
locations = []
return templates.TemplateResponse(
request=request,
name="index.html",
context={"locations": [item.model_dump() for item in locations]},
)
@router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather(location: str):
return RedirectResponse(f"{location}/day/today")
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
# TODO: tag mode route deprecated, used in tizen app
@router.get("/{provider}/{location}/tag/days-{days}", response_class=RedirectResponse)
async def get_tag_days(days: int):
return RedirectResponse(f"../days/{days}")
# TODO: tag mode route deprecated, used in tizen app
@router.get("/{provider}/{location}/{mode:str}/{date}", response_class=HTMLResponse)
async def get_weather_day(
request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: DateDepends,
mode: Literal["day", "tag"], # pylint:disable=unused-argument
):
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@@ -35,18 +35,17 @@
</div>
</form>
{% if locations %}
<div class="mb-4">
<weather-location-list value="{{locations | tojson | forceescape}}"
onclick="weatherLocationManager.store(element)"
onadd="weatherLocationManager.add(element)"></weather-location-list>
<ul id="locations"
class="list-group mb-5">
{% for location in locations %}
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
{% endfor %}
</ul>
<hr>
</div>
{% endif %}
<div class="mb-4">
<h3>{{_("Stored locations")}}</h3>
<weather-location-list id="storedLocations"
onremove="weatherLocationManager.remove(element)"></weather-location-list>
</div>
<ul id="storedLocations"
class="list-group mb-5">
</ul>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
@@ -56,7 +55,7 @@
}
document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.load();
weatherLocationManager.loadLocations('#storedLocations');
});
})();
</script>
@@ -1,8 +1,5 @@
{% extends "base.html" %}
{% set date = response.date %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}{% endblock %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block header %}
<app-link href="/weather"
@@ -12,23 +9,21 @@ locale=request.state.language)}}{% endblock %}
{% block content %}
<h4>
{% if response.period == 'day' %}
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="{{build_url(request, path=['..', '..', 'days', '10'])}}">
href="../tag/days-10">
<i class="bi bi-arrow-up-square"></i>
</a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
{% endif %}
{% if response.period == 'days' %}
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
locale=request.state.language)}}</span>
@@ -50,7 +45,7 @@ locale=request.state.language)}}{% endblock %}
{% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
<a href="{{build_url(request, path=['..', '..', 'day', value.date.date()])}}">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
{{format_date(value.date, 'E d', locale=request.state.language)}}
</a>
</span>
@@ -107,8 +102,8 @@ locale=request.state.language)}}{% endblock %}
{% for value in response.values %}
<td class="wind"
data-bs-toggle="tooltip"
data-bs-title="{{ value.wind.direction }}">
<div class="wi-svg wi-s wi-{{value.wind.direction | wind_direction_icon}}"
data-bs-title="{{ value.wind_direction }}">
<div class="wi-svg wi-s wi-{{value.wind_direction | wind_direction_icon}}"
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
</td>
{% endfor %}
@@ -123,11 +118,11 @@ locale=request.state.language)}}{% endblock %}
<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 and value.wind.gust > 0 %}
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}})
({{value.wind_gust}})
</span>
{% endif %}
</td>
@@ -177,7 +172,7 @@ locale=request.state.language)}}{% endblock %}
{% for value in response.values %}
<td class="humidity"
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
{% if value.humidity > -1%}<span class="value">{{value.humidity}}</span>{% endif %}
<span class="value">{{value.humidity}}</span>
</td>
{% endfor %}
</tr>
+4 -8
View File
@@ -5,23 +5,19 @@ import uvicorn
from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTVApi
from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.rp5.api import RP5WeatherApi
from gallery.painting.yandextv.api import YandexTVApi
from gallery.painting.yandexweather.api import YandexWeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi
api = ApiBundle(
[
CachedScheduleApi(YandexTVApi()),
CachedScheduleApi(MatchTVApi()),
CachedScheduleApi(YandexTvApi()),
CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
CachedWeatherApi(YandexWeatherApi()),
CachedWeatherApi(RP5WeatherApi()),
]
)
app = build_app(api)
-1
View File
@@ -1 +0,0 @@
PROVIDER = "gismeteo"
+92 -31
View File
@@ -1,55 +1,116 @@
import datetime
from os import environ
import json
import logging
from typing import Any
from bs4 import BeautifulSoup
from fastapi import HTTPException, status
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from . import PROVIDER
from .parser import ForecastData, GismeteoForecastParser, GismeteoLocationParser
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
def format_date(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"
logger = logging.getLogger("gismeteo")
class GismeteoApi(WeatherApi):
PROVIDER = PROVIDER
forecast_parser = GismeteoForecastParser()
location_parser = GismeteoLocationParser()
def _build_source(self) -> ApiSource:
return ApiSource(
PROVIDER = "gismeteo"
SOURCE = ApiSource(
"https://www.gismeteo.ru",
cookies={"xauth": auth} if (auth := environ.get("GISMETEO_AUTH")) else None,
cookies={
"cf_clearance": (
"zPj8qeaYZWp5BUZsWIRD7kd32s59IDWWaVvD7GGf6b4-1724254262-1.2.1.1-"
"R1PaHoqlm4OEgHfrOg6oAJmVmFJ7053JI_QDjIFlCAmbNY0oyw1J4QtgmEkAkTWF4C99X5hpXmZB2en."
"9Ey3NL0kzaE10UGBHPli5OLxHNKC3wQ34xmxTxg6GcKouXOPRgqo92lVRpQP7ghvDmvvT_"
"S6YXDbRonkamptWhZiAsIhsnIQg2Mt7Lk1fxSEVbaey_RXVQBvaR2ofiFgIavcbQPRFSvJ."
"Ct_FujmymTAZWh1XHTptK32kur81NXyPuARThetOKQm8AklaMa855Oom6Bms1a4F9b0yEHSd2inA4j3FR7uauu8hhwO0Z2goa3."
"ktpXXAQLz6dm.fBEWIHD7wooda15X57tCcDOely4hxhO4LK2lIH6TbOr."
"rZQAiRRhltHiCizP1Ou2SiF_81lb1umoDh739tQD1cnw_USnL."
"JwTHvkB00ZnaHRMi4FCBrsKIE0NxJO6kiv1QANqumzf5.8_pSIGRo2cKeO8xCydduDNFbStdOmLR6FU7ZKqVu"
)
},
)
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 find_locations(self, query: str) -> list[Location]:
geo = "ru"
latitude = 52.968498
longitude = 36.0695
data = await self.source.request(
data = json.loads(
await self.SOURCE.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
return self.location_parser.parse(data)
)
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
provider=self.provider,
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
country_code=item["country"]["code"].lower(),
district=(
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
),
subdistrict=(
item["translations"]["kk"]["subdistrict"]["name"]
if "subdistrict" in item["translations"]["kk"]
else ""
),
)
)
return result
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
max_date = datetime.date.today() + datetime.timedelta(days=9)
if date > max_date:
raise ValueError(f"Invalid date: {date}, max: {max_date}")
data = await self.source.request(f"weather-{location_id}/{format_date(date)}")
context = ForecastData(date, "day", data, 8)
return self.forecast_parser.parse(context)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
)
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
return self._parse_oneday(date, data)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
data = await self.source.request(f"weather-{location_id}/{days}-days")
context = ForecastData(datetime.date.today(), "days", data)
return self.forecast_parser.parse(context)
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
return self._parse_manydays(data)
+31
View File
@@ -0,0 +1,31 @@
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
+39
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"
+103 -145
View File
@@ -1,59 +1,59 @@
import datetime
import logging
import re
from dataclasses import dataclass
from typing import Any, Literal
from typing import Iterable
import dateparser
from bs4 import Tag
from gallery.sketch.parse.core import ParsedContext, ParseError, Parser
from gallery.sketch.parse.date import DATE_PARSER
from gallery.sketch.parse.html import HTMLContextParser, TableParser, TableValueParser
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.weather.model import (
Cloudness,
Location,
Precipitation,
Sky,
WeatherResponse,
WeatherValue,
WindDirection,
WindDirectionDeg,
)
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
from gallery.sketch.weather.util import fix_dates_sequence, merge_weather_values
from . import PROVIDER
from .core import BaseWidgetParser, RowParser
logger = logging.getLogger("gismeteo")
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
class LocationNameParser(Parser[str, str]):
class LocationParser:
PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"')
def parse(self, data: str) -> str:
def parse_location(self, data: str) -> str | None:
match = self.PATTERN.search(data)
if match:
return match.group(1)
raise ParseError("Location not found")
return None
class TimeParser(TableValueParser[datetime.datetime]):
selector = ".widget-row.widget-row-datetime-time > .row-item > time-value"
model_key = "date"
def extract(self, data: Tag) -> datetime.datetime:
timestamp = int(data.attrs["timestamp"])
return datetime.datetime.fromtimestamp(timestamp)
LOCATION_PARSER = LocationParser()
class DateParser(TableValueParser[datetime.datetime]):
selector = ".widget-row.widget-row-date > .row-item"
model_key = "date"
class DateParser(RowParser[datetime.datetime]):
KEY = "date"
def extract(self, data: Tag) -> datetime.datetime:
return DATE_PARSER.parse(data.text)
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
if datetime_time_row:
for item in datetime_time_row.select(".row-item > time-value"):
timestamp = int(item.attrs["timestamp"])
time = datetime.datetime.fromtimestamp(timestamp)
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(TableValueParser[Sky]):
selector = ".widget-row[data-row=icon-tooltip] > .row-item"
model_key = "sky"
class SkyParser(RowParser[Sky]):
KEY = "sky"
CLOUDNESS_MAP: dict[str, Cloudness] = {
"ясно": Cloudness.CLEAR,
@@ -65,7 +65,6 @@ class SkyParser(TableValueParser[Sky]):
PRECIPITATION_MAP: dict[str, Precipitation] = {
"без осадков": Precipitation.NO,
"без существенных осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN,
"сильный дождь": Precipitation.HEAVY_RAIN,
"очень сильный дождь": Precipitation.HEAVY_RAIN,
@@ -83,21 +82,23 @@ class SkyParser(TableValueParser[Sky]):
"град": Precipitation.HAIL,
}
THUNDER = {"гроза"}
FOG = {"дымка", "туман"}
THUNDER = "гроза"
FOG = "дымка"
@classmethod
def _detect_flag(cls, values: set[str], flag_values: set[str]) -> tuple[set[str], bool]:
result_values = values - flag_values
return result_values, len(result_values) < len(values)
def extract(self, data: Tag) -> Sky:
sky_str = data.attrs["data-tooltip"]
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
values, thunder = self._detect_flag(values, self.THUNDER)
values, fog = self._detect_flag(values, self.FOG)
thunder = False
fog = False
if self.THUNDER in values:
thunder = True
values.remove(self.THUNDER)
if self.FOG in values:
fog = True
values.remove(self.FOG)
for k, v in self.CLOUDNESS_MAP.items():
if k in values:
cloudness = v
@@ -109,8 +110,8 @@ class SkyParser(TableValueParser[Sky]):
values.remove(k)
break
if values:
raise ParseError("Unknown sky values", values)
return Sky(
logger.warning("unknown sky values: %s:", values)
yield Sky(
cloudness=cloudness,
precipitation=precipitation,
thunder=thunder,
@@ -118,69 +119,84 @@ class SkyParser(TableValueParser[Sky]):
)
class TemperatureParser(TableValueParser[list[int]]):
selector = ".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
model_key = "temperature"
class TemperatureParser(RowParser[list[int]]):
KEY = "temperature"
def extract(self, data: Tag) -> list[int]:
return [int(value.attrs["value"]) for value in data.select("temperature-value")]
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(TableValueParser[int]):
selector = ".widget-row-wind > .row-item > .wind-speed"
model_key = "wind.speed"
class WindSpeedParser(RowParser[int]):
KEY = "wind_speed"
def extract(self, data: Tag) -> int:
value = data.select_one("speed-value")
return int(value.attrs["value"]) if value else 0
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
yield int(item.attrs["value"])
class WindGustParser(TableValueParser[int]):
selector = ".widget-row-wind > .row-item > .wind-gust"
model_key = "wind.gust"
class WindGustParser(RowParser[int]):
KEY = "wind_gust"
def extract(self, data: Tag) -> int:
value = data.select_one("speed-value")
return int(value.attrs["value"]) if value else 0
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
value = item.select_one("speed-value")
yield int(value.attrs["value"]) if value else 0
class WindDirectionParser(TableValueParser[WindDirection | None]):
selector = ".widget-row-wind > .row-item > .wind-speed > .wind-direction"
model_key = "wind.direction"
class WindDirectionParser(RowParser[WindDirection]):
KEY = "wind_direction"
def extract(self, data: Tag) -> WindDirection | None:
return WIND_DIRECTION_PARSER.parse(data.text.strip())
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"—": WindDirection.CALM,
"штиль": WindDirection.CALM,
"с": WindDirection.N,
"св": WindDirection.NE,
"в": WindDirection.E,
"юв": WindDirection.SE,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
wind_direction_str = item.text.lower().strip()
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
class PrecipitationParser(TableValueParser[float]):
selector = ".widget-row[data-row=precipitation-bars] > .row-item"
model_key = "precipitation"
class PrecipitationParser(RowParser[float]):
KEY = "precipitation"
def extract(self, data: Tag) -> float:
value = data.select_one("precipitation-value")
return float(value.attrs["value"]) if value else 0
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
value = item.select_one("precipitation-value")
if value:
yield float(value.attrs["value"])
else:
yield 0
class PressureParser(TableValueParser[list[int]]):
selector = ".widget-row-chart[data-row=pressure] > .chart > .values > .value"
model_key = "pressure"
class PressureParser(RowParser[list[int]]):
KEY = "pressure"
def extract(self, data: Tag) -> list[int]:
return [int(value.attrs["value"]) for value in data.select("pressure-value")]
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(TableValueParser[int]):
selector = ".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
model_key = "humidity"
class HumidityParser(RowParser[int]):
KEY = "humidity"
def extract(self, data: Tag) -> int:
return int(data.text)
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
):
yield int(item.text)
class GismeteoTableParser(TableParser[WeatherValue]):
selector = ".widget .widget-items"
value_parsers = [
TimeParser(),
ROW_PARSERS: list[RowParser] = [
DateParser(),
SkyParser(),
TemperatureParser(),
@@ -192,62 +208,4 @@ class GismeteoTableParser(TableParser[WeatherValue]):
HumidityParser(),
]
def create_model(self, data: dict) -> WeatherValue:
return WeatherValue.model_validate(data)
@dataclass
class ForecastData:
date: datetime.date
period: Literal["day", "days"]
data: str
values: int | None = None
class GismeteoForecastParser(HTMLContextParser[ForecastData, WeatherResponse]):
location_parser = LocationNameParser()
table_parser = GismeteoTableParser()
def extract(self, data: ParsedContext[ForecastData, Tag]) -> WeatherResponse:
location = self.location_parser.parse(data.context.data)
values = self.table_parser.parse(data.data)
if data.context.values and len(values) > data.context.values:
n = int(len((values)) / data.context.values)
values = [
merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n)
]
if data.context.period == "days":
fix_dates_sequence(values)
return WeatherResponse(
location=location,
date=data.context.date,
period=data.context.period,
values=values,
)
class GismeteoLocationParser(JSONParser[list[Location]]):
def extract(self, data: Any) -> list[Location]:
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
provider=PROVIDER,
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
country_code=item["country"]["code"].lower(),
district=(
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
),
subdistrict=(
item["translations"]["kk"]["subdistrict"]["name"]
if "subdistrict" in item["translations"]["kk"]
else ""
),
)
)
return result
ROW_PARSERS_MAP: dict[str, RowParser] = {parser.KEY: parser for parser in ROW_PARSERS}
-1
View File
@@ -1 +0,0 @@
PROVIDER = "matchtv"
+42 -20
View File
@@ -1,33 +1,55 @@
import datetime
import json
import logging
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
from . import PROVIDER
from .parser import (
ChannelData,
MatchTVChannelParser,
MatchTVScheduleParser,
ScheduleData,
)
logger = logging.getLogger("matchtv")
class MatchTVApi(ScheduleApi):
PROVIDER = PROVIDER
schedule_parser = MatchTVScheduleParser()
channel_parser = MatchTVChannelParser()
def _build_source(self) -> ApiSource:
return ApiSource("https://matchtv.ru")
class MatchTvApi(ScheduleApi):
PROVIDER = "matchtv"
SOURCE = ApiSource("https://matchtv.ru")
async def find_channels(self, query: str) -> list[Channel]:
endpoint = "api/v1/channels"
data = await self.source.request(endpoint)
return self.channel_parser.parse(ChannelData(query, data))
data = json.loads(await self.SOURCE.request(endpoint))
result = []
query = query.lower()
for item in data["result"]:
if query in item["name"].lower() or query in item["alias"]:
channel_id = item["alias"]
name = item["name"].split("|")[0].strip()
result.append(Channel(id=channel_id, name=name, provider=self.provider))
return result
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
data = await self.source.request(endpoint)
return self.schedule_parser.parse(ScheduleData(date, data))
data = json.loads(await self.SOURCE.request(endpoint))
channel_data = data["result"]["channels"][0]
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
values = []
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in channel_data["schedule"]:
title = item["title"]
time_str = item["time"]
hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
current_day += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = "Прямая трансляция" in title
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=channel,
date=date,
values=values,
)
-71
View File
@@ -1,71 +0,0 @@
import datetime
import re
from dataclasses import dataclass
from typing import Any
from gallery.sketch.parse.core import ParsedContext, Parser
from gallery.sketch.parse.json import JSONContextParser
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from . import PROVIDER
class DateParser(Parser[str, datetime.date]):
def parse(self, data: str) -> datetime.date:
matched = re.compile('{\\"date\\":(\\d+)}').search(data)
return datetime.date.strptime(matched.group(1), data)
@dataclass
class ScheduleData:
date: datetime.date
data: str
class MatchTVScheduleParser(JSONContextParser[ScheduleData, Schedule]):
date_parser = DateParser()
def extract(self, data: ParsedContext[ScheduleData, Any]) -> Schedule:
channel_data = data.data["result"]["channels"][0]
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=PROVIDER)
values = []
start = datetime.datetime.combine(data.context.date, datetime.time.min)
end = start + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in channel_data["schedule"]:
title = item["title"]
time_str = item["time"]
hours, minutes = map(int, time_str.split(":"))
item_date = start.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
start += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = "Прямая трансляция" in title
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=channel,
date=data.context.date,
values=values,
)
@dataclass
class ChannelData:
query: str
data: str
class MatchTVChannelParser(JSONContextParser[ChannelData, list[Channel]]):
def extract(self, data: ParsedContext[ChannelData, Any]) -> list[Channel]:
result = []
query = data.context.query.lower()
for item in data.data["result"]:
if query in item["name"].lower() or query in item["alias"]:
channel_id = item["alias"]
name = item["name"].split("|")[0].strip()
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
return result
-1
View File
@@ -1 +0,0 @@
PROVIDER = "openweather"
+41 -26
View File
@@ -1,5 +1,6 @@
import datetime
from itertools import groupby
import logging
from collections import defaultdict
from os import environ
from aiocache import cached
@@ -9,20 +10,17 @@ from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from . import PROVIDER
from .openweather import Forecast
from .openweather import Location as OpenWeatherLocation
from .openweather import OpenWeather
from .parser import OpenWeatherForecastParser, OpenWeatherLocationParser
from .parser import FORECAST_ITEM_PARSER
logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi[OpenWeather]):
PROVIDER = PROVIDER
forecast_parser = OpenWeatherForecastParser()
location_parser = OpenWeatherLocationParser()
def _build_source(self) -> OpenWeather:
return OpenWeather(environ["OPENWEATHER_KEY"])
class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather"
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
@classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]:
@@ -34,24 +32,41 @@ class OpenWeatherApi(WeatherApi[OpenWeather]):
ttl=TimeUnit.DAY,
)
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
return await self.source.get_location(*self._parse_location(location_id))
return await self.SOURCE.get_location(*self._parse_location(location_id))
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
ttl=TimeUnit.DAY,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherValue]:
content = await self.source.get_forecast(*self._parse_location(location_id))
return self.forecast_parser.parse(content)
async def _get_location_forecast(self, location_id: str) -> Forecast:
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]:
content = await self.source.find_locations(query)
return self.location_parser.parse(content)
result = await self.SOURCE.find_locations(query)
return [
Location(
id=f"{item.lat}:{item.lon}",
name=item.name,
provider=self.provider,
lat=item.lat,
lon=item.lon,
country=item.country,
country_code=item.country.lower(),
district=item.state or "",
subdistrict="",
)
for item in result
]
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
location: OpenWeatherLocation = await self._get_location(location_id)
values = [value for value in await self._get_location_forecast(location_id) if value.date.date() == date]
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)
return WeatherResponse(
location=location.name,
date=date,
@@ -61,13 +76,13 @@ class OpenWeatherApi(WeatherApi[OpenWeather]):
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
location: OpenWeatherLocation = await self._get_location(location_id)
values = [
merge_weather_values(datetime.datetime.combine(k, datetime.datetime.min.time()), list(g))
for k, g in groupby(
sorted(await self._get_location_forecast(location_id), key=lambda item: item.date.date()),
lambda item: item.date.date(),
)
]
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()]
return WeatherResponse(
location=location.name,
date=datetime.date.today(),
+5 -5
View File
@@ -76,28 +76,28 @@ class Location(Model):
local_names: dict[str, str] | None = None
class OpenWeather(ApiSource):
class OpenWeather:
BASE_URL = "https://api.openweathermap.org"
def __init__(self, api_key: str):
self._api_key = api_key
super().__init__(self.BASE_URL)
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.request(endpoint)
response = await self._source.request(endpoint)
response_data = json.loads(response)
return Forecast.model_validate(response_data)
async def find_locations(self, query: str, limit: int = 5) -> list[Location]:
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
response = await self.request(endpoint)
response = await self._source.request(endpoint)
response_data = json.loads(response)
return [Location.model_validate(item) for item in response_data]
async def get_location(self, lat: float, lon: float) -> Location:
limit = 1
endpoint = f"geo/1.0/reverse?lat={lat}&lon={lon}&limit={limit}&appid={self._api_key}"
response = await self.request(endpoint)
response = await self._source.request(endpoint)
response_data = json.loads(response)
return Location.model_validate(response_data[0])
+22 -62
View File
@@ -1,21 +1,12 @@
import datetime
from gallery.sketch.parse.core import Parser
from gallery.sketch.weather.model import (
Cloudness,
Location,
Precipitation,
WeatherValue,
)
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
from gallery.sketch.weather.util import convert_hPa_to_mmHg
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
from gallery.sketch.weather.util import build_weather_value
from . import PROVIDER
from .openweather import Forecast, ForecastItem
from .openweather import Location as OpenWeatherLocation
from .openweather import ForecastItem
class ForecastItemParser(Parser[ForecastItem, list[WeatherValue]]):
class ForecastItemParser:
CLOUDNESS_MAP: dict[str, Cloudness] = {
"clear sky": Cloudness.CLEAR,
"few clouds": Cloudness.PARTLY_CLOUDY,
@@ -31,54 +22,23 @@ class ForecastItemParser(Parser[ForecastItem, list[WeatherValue]]):
"heavy rain": Precipitation.SHOWER,
}
def parse(self, data: ForecastItem) -> WeatherValue:
date = (
datetime.datetime.fromtimestamp(data.dt, datetime.UTC)
.replace(tzinfo=datetime.timezone.utc)
.astimezone(tz=None)
.replace(tzinfo=None)
)
return WeatherValue.model_validate(
{
"date": date,
"sky": {
"cloudness": self.CLOUDNESS_MAP.get(data.weather[0].description, Cloudness.CLEAR),
"precipitation": self.PRECIPITATION_MAP.get(data.weather[0].description, Precipitation.NO),
},
"wind": {
"speed": round(data.wind.speed),
"gust": round(data.wind.gust),
"direction": WIND_DIRECTION_PARSER.parse(data.wind.deg),
},
"temperature": [round(data.main.temp)], # [round(data.main.temp_max), round(data.main.temp_min)]
"precipitation": round(data.rain.interval_3h, 1) if data.rain else 0,
"pressure": [convert_hPa_to_mmHg(data.main.pressure)],
"humidity": data.main.humidity,
}
)
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
class OpenWeatherForecastParser(Parser[Forecast, list[WeatherValue]]):
item_parser = ForecastItemParser()
def parse(self, data: Forecast) -> list[WeatherValue]:
return [self.item_parser.parse(item) for item in data.list]
class OpenWeatherLocationParser(Parser[list[OpenWeatherLocation], list[Location]]):
def parse(self, data: list[OpenWeatherLocation]) -> list[Location]:
return [
Location(
id=f"{item.lat}:{item.lon}",
name=item.name,
provider=PROVIDER,
lat=item.lat,
lon=item.lon,
country=item.country,
country_code=item.country.lower(),
district=item.state or "",
subdistrict="",
)
for item in data
]
FORECAST_ITEM_PARSER = ForecastItemParser()
-1
View File
@@ -1 +0,0 @@
PROVIDER = "rp5"
-62
View File
@@ -1,62 +0,0 @@
import datetime
import aiohttp
from aiocache import cached
from gallery.sketch.headers import build_headers
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import (
Location,
WeatherResponse,
)
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from . import PROVIDER
from .parser import RP5ForecastParser, RP5LocationParser
class RP5WeatherApi(WeatherApi):
PROVIDER = PROVIDER
forecast_parser = RP5ForecastParser()
location_parser = RP5LocationParser()
def _build_source(self) -> ApiSource:
return ApiSource("https://rp5.ru", headers=build_headers("rp5.ru"))
async def find_locations(self, query: str) -> list[Location]:
endpoint = "docs/search/ru"
data = aiohttp.FormData({"searchStr": query})
content = await self.source.request(endpoint, method="POST", data=data)
return self.location_parser.parse(content)
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
endpoint = f"{location_id}"
content = await self.source.request(endpoint)
return self.forecast_parser.parse(content)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
for response in forecast:
if response.date == date:
return response
if date == datetime.date.today():
return WeatherResponse(location=location_id, date=date, period="day", values=[])
raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
values = [merge_weather_values(item.date, item.values) for item in forecast]
return WeatherResponse(
location=forecast[0].location,
date=datetime.date.today(),
period="days",
values=values,
)
-223
View File
@@ -1,223 +0,0 @@
import datetime
import re
from bs4 import Tag
from gallery.sketch.parse.core import ParseError
from gallery.sketch.parse.date import DATE_PARSER
from gallery.sketch.parse.html import (
HTMLParser,
TableParser,
TableValueParser,
TagIterParser,
TagParser,
)
from gallery.sketch.weather.model import (
Cloudness,
Location,
Precipitation,
WeatherResponse,
WeatherValue,
WindDirection,
)
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
from gallery.sketch.weather.util import fix_dates_sequence
from . import PROVIDER
class LocationNameParser(TagParser[str]):
selector = ".intoLeftNavi > .verticalBottom > a"
def extract(self, data: Tag) -> str:
return data.text.strip()
class DatesParser(TagIterParser[tuple[datetime.datetime, int]]):
selector = "#forecastTable > tr.forecastDate > td"
def extract(self, data: Tag) -> tuple[datetime.datetime, int]:
colspan = int(data.attrs["colspan"])
index = data.parent.index(data)
if index == 1:
colspan -= 1
date_str = ",".join(data.text.strip().split(",")[-2:])
date = DATE_PARSER.parse(date_str)
return (date, colspan)
class TimeParser(TableValueParser[datetime.datetime]):
selector = "tr.forecastTime > td:not(:first-of-type)"
model_key = "date"
def extract(self, data: Tag) -> datetime.datetime:
hour = int(data.text.strip())
return datetime.datetime.min.replace(hour=hour)
class CloudnessParser(TableValueParser[Cloudness]):
selector = "tr:nth-of-type(3) > td:not(:first-of-type)"
model_key = "sky.cloudness"
CLOUDNESS_MAP = {
"Ясно": Cloudness.CLEAR,
"Малооблачно": Cloudness.PARTLY_CLOUDY,
"Облачно с прояснениями": Cloudness.PARTLY_CLOUDY,
"Переменная облачность": Cloudness.PARTLY_CLOUDY,
"Небольшая облачность": Cloudness.CLOUDY,
"Облачно": Cloudness.CLOUDY,
"Значительная облачность": Cloudness.MAINLY_CLOUDY,
"Пасмурная погода": Cloudness.MAINLY_CLOUDY,
}
def extract(self, data: Tag) -> Cloudness:
onmouseover = data.select_one(".cc_0").next_element.attrs["onmouseover"]
cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip()
if cloudness_str not in self.CLOUDNESS_MAP:
raise ParseError("Unknown cloudness value", cloudness_str)
return self.CLOUDNESS_MAP[cloudness_str]
class PrecipitationParser(TableValueParser[tuple[Precipitation, float]]):
selector = "tr:nth-of-type(4) > td:not(:first-of-type)"
model_key = ["sky.precipitation", "precipitation"]
PRECIPITATION_MAP = {
"Явления погоды отсутствуют": Precipitation.NO,
"Cлабый дождь": Precipitation.SMALL_RAIN,
"Умеренный дождь": Precipitation.RAIN,
}
def extract(self, data: Tag) -> tuple[Precipitation, float]:
onmouseover = data.select_one(".pr_0").attrs["onmouseover"]
precipitation_str = onmouseover.split(",")[1].strip(" '")
precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str)
precipitation_value = 0
if precipitation_value_matched:
# one hour value
precipitation_value = float(precipitation_value_matched.group(1)) * 6
if " (" in precipitation_str:
precipitation_str = precipitation_str.split(" (")[0]
if precipitation_str not in self.PRECIPITATION_MAP:
raise ParseError("Unknown precipitation value", precipitation_str)
return self.PRECIPITATION_MAP[precipitation_str], precipitation_value
class TemperatureParser(TableValueParser[list[int]]):
selector = "tr:nth-of-type(5) > td:not(:first-of-type)"
model_key = "temperature"
def extract(self, data: Tag) -> list[int]:
return [int(data.select_one(".t_0").text.lstrip("+").strip())]
class PressureParser(TableValueParser[list[int]]):
selector = "tr:nth-last-of-type(8) > td:not(:first-of-type)"
model_key = "pressure"
def extract(self, data: Tag) -> list[int]:
return [int(data.select_one(".p_0").text.strip())]
class WindSpeedParser(TableValueParser[int]):
selector = "tr:nth-last-of-type(7) > td:not(:first-of-type)"
model_key = "wind.speed"
def extract(self, data: Tag) -> int:
element = data.select_one(".wv_0")
if element:
return int(element.text.strip() or "0")
return 0
class WindGustParser(WindSpeedParser):
selector = "tr:nth-last-of-type(6) > td:not(:first-of-type)"
model_key = "wind.gust"
class WindDirectionParser(TableValueParser[WindDirection | None]):
selector = "tr:nth-last-of-type(5) > td:not(:first-of-type)"
model_key = "wind.direction"
def extract(self, data: Tag) -> WindDirection | None:
return WIND_DIRECTION_PARSER.parse(data.text.strip())
class HumidityParser(TableValueParser[int]):
selector = "tr:nth-last-of-type(4) > td:not(:first-of-type)"
model_key = "humidity"
def extract(self, data: Tag) -> int:
return int(data.text.strip())
class RP5TableParser(TableParser[WeatherValue]):
selector = "#forecastTable"
value_parsers = [
TimeParser(),
CloudnessParser(),
PrecipitationParser(),
TemperatureParser(),
PressureParser(),
WindSpeedParser(),
WindGustParser(),
WindDirectionParser(),
HumidityParser(),
]
def create_model(self, data: dict) -> WeatherValue:
return WeatherValue.model_validate(data)
class RP5ForecastParser(HTMLParser[list[WeatherResponse]]):
location_parser = LocationNameParser()
dates_parser = DatesParser()
table_parser = RP5TableParser()
def extract(self, data: Tag) -> list[WeatherResponse]:
location = self.location_parser.parse(data)
dates = self.dates_parser.parse(data)
values = self.table_parser.parse(data)
responses = []
for date, count in dates:
item_values, values = values[:count], values[count:]
for value in item_values:
value.date = date.replace(hour=value.date.hour)
responses.append(
WeatherResponse(
location=location,
date=date,
period="day",
values=item_values,
)
)
fix_dates_sequence(responses)
return responses
class RP5LocationParser(HTMLParser[list[Location]]):
selector = ".searchResults"
def extract(self, data: Tag) -> list[Location]:
result = []
for row in data.select("tr")[1:]:
cells = row.select("td")
name = cells[0].text.strip()
location_id = cells[0].select_one("a").attrs["href"].lstrip("/")
country = cells[1].text.strip()
district = cells[2].text.strip()
subdistrict = cells[3].text.strip()
result.append(
Location(
id=location_id,
name=name,
provider=PROVIDER,
lat=0,
lon=0,
country=country,
country_code="ru", # TODO: contry_code from country
district=district,
subdistrict=subdistrict,
)
)
return result
-1
View File
@@ -1 +0,0 @@
PROVIDER = "yandex"
+74 -18
View File
@@ -1,34 +1,90 @@
import datetime
import json
import logging
from bs4 import BeautifulSoup
from gallery.sketch.headers import build_headers
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
from . import PROVIDER
from .parser import ScheduleData, YandexTVChannelParser, YandexTVScheduleParser
logger = logging.getLogger("matchtv")
class YandexTVApi(ScheduleApi):
PROVIDER = PROVIDER
HEADERS: dict[str, str] = {
"Accept": (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,image/webp,"
"image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.9"
),
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Connection": "keep-alive",
"Host": "tv.yandex.ru",
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/100.0.4896.133 "
"Safari/537.36"
),
}
schedule_parser = YandexTVScheduleParser()
channel_parser = YandexTVChannelParser()
lr = 213 # magic id
def _build_source(self) -> ApiSource:
return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
async def find_channels(self, query: str) -> list[Channel]:
url = (
"https://suggest-multi.yandex.ru/suggest-tv2?"
f"v=4&uil=ru&lr={self.lr}&count_channels=4&count_programs=4&sn=50&part={query}"
f"v=4&uil=ru&lr=10&count_channels=4&count_programs=4&sn=50&part={query}"
)
data = await self.source.request(url)
return self.channel_parser.parse(data)
_, values = json.loads(await self.SOURCE.request(url))
result = []
for _, name, content in values:
if content["label"] == "Каналы":
channel_id = content["url"].split("/")[-1]
result.append(Channel(id=channel_id, name=name, provider=self.provider))
return result
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
endpoint = f"{self.lr}/channel/{channel_id}?date={date:%Y-%m-%d}"
data = await self.source.request(endpoint)
return self.schedule_parser.parse(ScheduleData(date, channel_id, data))
endpoint = f"channels/{channel_id}?date={date:%Y-%m-%d}"
data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser")
if soup.select_one(".CheckboxCaptcha") is not None:
raise RuntimeError("Captcha")
values = []
channel_name = soup.select_one(".channel-header__text").text.strip()
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in soup.select(".channel-schedule .channel-schedule__event"):
title = item.select_one(".channel-schedule__title").text.strip()
time_str = item.select_one(".channel-schedule__time").text.strip()
hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
current_day += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = item.select_one(".channel-schedule__info .icon_live") is not None
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=Channel(id=channel_id, name=channel_name, provider=self.provider),
date=date,
values=values,
)
-60
View File
@@ -1,60 +0,0 @@
import datetime
from dataclasses import dataclass
from typing import Any
from bs4 import Tag
from gallery.sketch.parse.core import ParsedContext, ParseError
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from ...sketch.parse.html import HTMLContextParser
from . import PROVIDER
@dataclass
class ScheduleData:
date: datetime.date
channel_id: str
data: str
class YandexTVScheduleParser(HTMLContextParser[ScheduleData, Schedule]):
def extract(self, data: ParsedContext[ScheduleData, Tag]) -> Schedule:
if data.data.select_one(".CheckboxCaptcha") is not None:
raise ParseError("Captcha")
values = []
channel_name = data.data.select_one(".channel-header__text").text.strip()
current_day = datetime.datetime.combine(data.context.date, datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in data.data.select(".channel-schedule .channel-schedule__event"):
title = item.select_one(".channel-schedule__title").text.strip()
time_str = item.select_one(".channel-schedule__time").text.strip()
hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
current_day += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = item.select_one(".channel-schedule__info .icon_live") is not None
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=Channel(id=data.context.channel_id, name=channel_name, provider=PROVIDER),
date=data.context.date,
values=values,
)
class YandexTVChannelParser(JSONParser[list[Channel]]):
def extract(self, data: Any) -> list[Channel]:
_, values = data
result = []
for _, name, content in values:
if content["label"] == "Каналы":
channel_id = content["url"].split("/")[-1]
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
return result
@@ -1 +0,0 @@
PROVIDER = "yandex"
-57
View File
@@ -1,57 +0,0 @@
import datetime
from aiocache import cached
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import (
Location,
WeatherResponse,
)
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from . import PROVIDER
from .parser import YandexForecastParser, YandexLocationParser
class YandexWeatherApi(WeatherApi):
PROVIDER = PROVIDER
forecast_parser = YandexForecastParser()
location_parser = YandexLocationParser()
def _build_source(self) -> ApiSource:
return ApiSource("https://yandex.ru")
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
endpoint = f"pogoda/ru/{location_id}/details"
content = await self.source.request(endpoint, mode="browser")
return self.forecast_parser.parse(content)
async def find_locations(self, query: str) -> list[Location]:
endpoint = f"weather/api/suggest?part={query}&lang=ru&type=weather"
response = await self.source.request(endpoint)
return self.location_parser.parse(response)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
for response in forecast:
if response.date == date:
return response
raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
values = [merge_weather_values(item.date, item.values) for item in forecast]
return WeatherResponse(
location=forecast[0].location,
date=datetime.date.today(),
period="days",
values=values,
)
-187
View File
@@ -1,187 +0,0 @@
import datetime
from typing import Any
from bs4 import Tag
from gallery.sketch.parse.core import ParseError
from gallery.sketch.parse.date import DATE_PARSER
from gallery.sketch.parse.html import (
HTMLParser,
TableParser,
TableValueParser,
TagParser,
)
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.weather.model import (
Cloudness,
Location,
Precipitation,
Sky,
WeatherResponse,
WeatherValue,
WindDirection,
)
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
from . import PROVIDER
class LocationNameParser(TagParser[str]):
selector = "[class^=Suggest_placeholder]"
def extract(self, data: Tag) -> str:
return data.text.strip()
class DateParser(TagParser[datetime.date]):
selector = "[class^=AppForecastDayHeader_dayTitle]"
def extract(self, data: Tag) -> datetime.date:
return DATE_PARSER.parse(data.text.split(",")[-1].strip())
class TimeParser(TableValueParser[datetime.datetime]):
selector = "[style$='-part']"
model_key = "date"
HOUR_MAP = {
"Утром": 6,
"Днём": 12,
"Вечером": 18,
"Ночью": 0,
}
def extract(self, data: Tag) -> datetime.datetime:
hour = self.HOUR_MAP[data.text.strip()]
return datetime.datetime.min.replace(hour=hour)
class TemperatureParser(TableValueParser[list[int]]):
selector = "[style$='-temp']"
model_key = "temperature"
def extract(self, data: Tag) -> list[int]:
return [int(data.text.strip().rstrip("°"))]
class SkyParser(TableValueParser[Sky]):
selector = "[style$='-text']"
model_key = "sky"
CLOUDNESS_MAP = {
"ясно": Cloudness.CLEAR,
"облачно с прояснениями": Cloudness.CLOUDY,
"малооблачно": Cloudness.CLOUDY,
"пасмурно": Cloudness.MAINLY_CLOUDY,
}
PRECIPITATION_MAP = {
"небольшой дождь": Precipitation.SMALL_RAIN,
"дождь": Precipitation.RAIN,
"ливень": Precipitation.SHOWER,
}
def extract(self, data: Tag) -> Sky:
sky = data.text.strip()
cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO
thunder = False
fog = False
if sky in self.CLOUDNESS_MAP:
cloudness = self.CLOUDNESS_MAP[sky]
elif sky in self.PRECIPITATION_MAP:
precipitation = self.PRECIPITATION_MAP[sky]
else:
raise ParseError("Unknown sky value", sky)
return Sky(
cloudness=cloudness,
precipitation=precipitation,
thunder=thunder,
fog=fog,
)
class WindSpeedParser(TableValueParser[int]):
selector = "[style$='-wind']:not([class*=AppForecastDayPart_wind_formatted])"
model_key = "wind.speed"
def extract(self, data: Tag) -> int:
return int(data.text.strip())
class WindDirectionParser(TableValueParser[WindDirection | None]):
selector = "[style$='-dir']"
model_key = "wind.direction"
def extract(self, data: Tag) -> WindDirection | None:
return WIND_DIRECTION_PARSER.parse(data.text.strip())
class HumidityParser(TableValueParser[int]):
selector = "[style$='-hum']"
model_key = "humidity"
def extract(self, data: Tag) -> int:
return int(data.text.strip().rstrip("%"))
class PressureParser(TableValueParser[list[int]]):
selector = "[style$='-press']"
model_key = "pressure"
def extract(self, data: Tag) -> list[int]:
return [int(data.text.strip().rstrip("%"))]
class YandexTableParser(TableParser[WeatherValue]):
selector = ""
value_parsers = [
TimeParser(),
TemperatureParser(),
SkyParser(),
WindSpeedParser(),
WindDirectionParser(),
HumidityParser(),
PressureParser(),
]
def create_model(self, data: dict) -> WeatherValue:
return WeatherValue.model_validate(data)
class YandexForecastParser(HTMLParser[list[WeatherResponse]]):
table_parser = YandexTableParser()
date_parser = DateParser()
location_parser = LocationNameParser()
def extract(self, data: Tag) -> list[WeatherResponse]:
location = self.location_parser.parse(data)
articles = data.select("article[class^=AppForecastDay]")
return [
WeatherResponse(
location=location,
date=self.date_parser.parse(item),
period="day",
values=self.table_parser.parse(item),
)
for item in articles
]
class YandexLocationParser(JSONParser[list[Location]]):
def extract(self, data: Any) -> list[Location]:
return [
Location(
id=str(item["geoId"]),
name=item["title"],
provider=PROVIDER,
lat=item["coords"]["lat"],
lon=item["coords"]["lon"],
country="ru",
country_code="ru",
district=item["description"],
subdistrict="",
)
for item in data
]
+1 -16
View File
@@ -1,19 +1,10 @@
from typing import TypeVar
from .source import ApiSource
S = TypeVar("S", bound=ApiSource)
class Api[S = ApiSource]:
class Api:
TYPE: str
PROVIDER: str
_source: S | None = None
def _build_source(self) -> S:
raise NotImplementedError
@property
def type(self) -> str:
return self.TYPE
@@ -22,11 +13,5 @@ class Api[S = ApiSource]:
def provider(self) -> str:
return self.PROVIDER
@property
def source(self) -> S:
if self._source is None:
self._source = self._build_source()
return self._source
API = TypeVar("API", bound=Api)
-4
View File
@@ -1,6 +1,5 @@
from typing import Generic, NamedTuple
from gallery.sketch.source import ApiSource
from gallery.util import TimeUnit
from .api import API, Api
@@ -20,9 +19,6 @@ class CachedApi(Api, Generic[API]):
def __init__(self, api: API):
self._api = api
def _build_source(self) -> ApiSource:
raise RuntimeError("Source not used in CachedApi")
@property
def provider(self) -> str:
return self._api.provider
-42
View File
@@ -1,42 +0,0 @@
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
DEFAULT_ACCEPT = (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,"
"image/webp,"
"image/apng,"
"*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.7"
)
DEFAULT_HEADERS = {
"Accept": DEFAULT_ACCEPT,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
"Cache-Control": "max-age=0",
"Connection": "keep-alive",
"Host": None,
"Referer": None,
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": DEFAULT_USER_AGENT,
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
}
def build_headers(host: str, *, user_agent: str | None = None) -> dict[str, str]:
result = DEFAULT_HEADERS.copy()
result["Host"] = host
result["Referer"] = f"https://{host}/"
if user_agent is not None:
result["User-Agent"] = user_agent
return result
View File
-49
View File
@@ -1,49 +0,0 @@
from abc import ABC, abstractmethod
from typing import Generic, NamedTuple, Protocol, TypeVar
class ParseError(Exception):
pass
D = TypeVar("D")
R = TypeVar("R")
class Parser[D, R](Protocol):
def parse(self, data: D) -> R: ...
P = TypeVar("P")
class PrepareParser(ABC, Parser[D, R], Generic[D, P, R]):
@abstractmethod
def prepare(self, data: D) -> P: ...
@abstractmethod
def extract(self, data: P) -> R: ...
def parse(self, data: D) -> R:
return self.extract(self.prepare(data))
class Context(Protocol):
data: str
C = TypeVar("C", bound=Context)
class ParsedContext(NamedTuple, Generic[C, D]):
context: C
data: D
class ContextParser(PrepareParser[C, ParsedContext[C, P], R], Generic[C, P, R]):
@abstractmethod
def prepare_context(self, data: str) -> P: ...
def prepare(self, data: C) -> ParsedContext[C, P]:
return ParsedContext(context=data, data=self.prepare_context(data.data))
-16
View File
@@ -1,16 +0,0 @@
import datetime
import dateparser
from .core import ParseError, Parser
class DateParser(Parser[str, datetime.datetime]):
def parse(self, data: str) -> datetime.datetime:
date = dateparser.parse(data, languages=["ru"])
if date is None:
raise ParseError("Invalid date", data)
return date
DATE_PARSER = DateParser()
-83
View File
@@ -1,83 +0,0 @@
from typing import Generic, Iterable
from bs4 import BeautifulSoup, Tag
from .core import C, ContextParser, ParseError, Parser, PrepareParser, R
from .util import set_dict_nested_value
def _resolve_tag(source: Tag, selector: str | None = None) -> Tag:
if selector:
tag = source.select_one(selector)
if tag is None:
raise ParseError("Tag not found", selector)
return tag
return source
class _HTMLParser(Parser[str, BeautifulSoup]):
def parse(self, data: str) -> BeautifulSoup:
return BeautifulSoup(data, features="html.parser")
HTML_PARSER = _HTMLParser()
class HTMLParser(PrepareParser[str, Tag, R], Generic[R]):
selector: str | None = None
def prepare(self, data: str) -> Tag:
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
class HTMLContextParser(ContextParser[C, Tag, R], Generic[C, R]):
selector: str | None = None
def prepare_context(self, data: str) -> Tag:
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
class TagParser(PrepareParser[Tag, Tag, R], Generic[R]):
selector: str | None = None
def prepare(self, data: Tag) -> Tag:
return _resolve_tag(data, self.selector)
class TagIterParser(Parser[Tag, Iterable[R]], Generic[R]):
selector: str
def extract(self, data: Tag) -> R:
raise NotImplementedError
def parse(self, data: Tag) -> Iterable[R]:
tags = data.select(self.selector)
for tag in tags:
yield self.extract(tag)
class TableValueParser(TagIterParser[R], Generic[R]):
model_key: str | list[str]
def extract(self, data: Tag) -> R:
raise NotImplementedError
class TableParser(TagParser[list[R]], Generic[R]):
value_parsers: list[TableValueParser]
def create_model(self, data: dict) -> R:
raise NotImplementedError
def extract(self, data: Tag) -> list[R]:
results = []
for parser in self.value_parsers:
for index, value in enumerate(parser.parse(data)):
while index > len(results) - 1:
results.append({})
if isinstance(parser.model_key, list):
for model_key_index, model_key in enumerate(parser.model_key):
set_dict_nested_value(results[index], model_key, value[model_key_index])
else:
set_dict_nested_value(results[index], parser.model_key, value)
return [self.create_model(item) for item in results]
-22
View File
@@ -1,22 +0,0 @@
import json
from typing import Any, Generic
from .core import C, ContextParser, Parser, PrepareParser, R
class _JSONParser(Parser[str, Any]):
def parse(self, data: str) -> Any:
return json.loads(data)
JSON_PARSER = _JSONParser()
class JSONParser(PrepareParser[str, Any, R], Generic[R]):
def prepare(self, data: str) -> Any:
return JSON_PARSER.parse(data)
class JSONContextParser(ContextParser[C, Any, R], Generic[C, R]):
def prepare_context(self, data: str) -> Any:
return JSON_PARSER.parse(data)
-17
View File
@@ -1,17 +0,0 @@
from typing import Any
def set_nested_attr(target: Any, key: str, value: Any):
parts = key.split(".")
for part in parts[:-1]:
target = getattr(target, part)
setattr(target, parts[-1], value)
def set_dict_nested_value(target: dict, key: str, value: Any):
parts = key.split(".")
for part in parts[:-1]:
if part not in target:
target[part] = {}
target = target[part]
target[parts[-1]] = value
+1 -2
View File
@@ -1,11 +1,10 @@
import datetime
from ..api import Api
from ..source import ApiSource
from .model import Channel, Schedule
class ScheduleApi[S = ApiSource](Api[S]):
class ScheduleApi(Api):
TYPE = "schedule"
async def find_channels(self, query: str) -> list[Channel]:
+14 -47
View File
@@ -1,74 +1,41 @@
import datetime
import logging
from typing import Any, Literal
import aiohttp
from playwright.async_api import async_playwright
logger = logging.getLogger("source")
RequestMode = Literal["base", "browser"]
DEFAULT_REQUEST_MODE: RequestMode = "base"
DEFAULT_METHOD: str = "GET"
class ApiSource:
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
DEFAULT_TIMEOUT = 30.0
def __init__(
self,
base_url,
user_agent: str = DEFAULT_USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
):
self._base_url = base_url
self._user_agent = user_agent
self._timeout = timeout
self._cookies = cookies
self._headers = headers
async def _base_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
async with aiohttp.ClientSession(
headers=self._headers,
cookies=self._cookies,
raise_for_status=True,
) as session:
async with session.request(method, url, timeout=self._timeout, data=data) as response:
return await response.text()
# pylint:disable=unused-argument
async def _browser_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(url, timeout=datetime.timedelta(seconds=self._timeout), wait_until="networkidle")
content = await page.content()
await browser.close()
return content
async def request(
self,
endpoint: str,
/,
mode: RequestMode = DEFAULT_REQUEST_MODE,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
async def request(self, endpoint: str) -> str:
if endpoint.startswith("https:"):
url = endpoint
else:
url = f"{self._base_url}/{endpoint}"
logger.info(url)
fun = getattr(self, f"_{mode}_request")
return await fun(url, method, data)
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
async with aiohttp.ClientSession(
headers=headers,
cookies=self._cookies,
raise_for_status=True,
) as session:
async with session.request("GET", url, timeout=self._timeout) as response:
return await response.text()
+1 -2
View File
@@ -1,11 +1,10 @@
import datetime
from ..api import Api
from ..source import ApiSource
from .model import Location, WeatherResponse
class WeatherApi[S = ApiSource](Api[S]):
class WeatherApi(Api):
TYPE = "weather"
async def find_locations(self, query: str) -> list[Location]:
+63 -27
View File
@@ -1,8 +1,8 @@
import datetime
from enum import StrEnum, auto
from typing import Literal, Self
from typing import Self
from pydantic import BaseModel, Field
from pydantic import BaseModel
class Model(BaseModel):
@@ -28,11 +28,6 @@ class Cloudness(StrEnum):
CLOUDY = auto()
MAINLY_CLOUDY = auto()
values: list[Self]
Cloudness.values = list(Cloudness)
class Precipitation(StrEnum):
NO = auto()
@@ -44,20 +39,16 @@ class Precipitation(StrEnum):
HEAVY_SNOW = auto()
HAIL = auto()
values: list[Self]
Precipitation.values = list(Precipitation)
class Sky(Model):
cloudness: Cloudness = Cloudness.CLEAR
precipitation: Precipitation = Precipitation.NO
thunder: bool = False
fog: bool = False
cloudness: Cloudness
precipitation: Precipitation
thunder: bool
fog: bool
class WindDirection(StrEnum):
CALM = auto()
N = auto()
NE = auto()
E = auto()
@@ -68,24 +59,69 @@ class WindDirection(StrEnum):
NW = auto()
class Wind(Model):
speed: int = 0
gust: int = 0
direction: WindDirection | None = None
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 == -1:
return WindDirection.CALM
elif 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:
raise ValueError(self)
@classmethod
def from_direction(cls, direction: WindDirection) -> Self:
return cls(
{
WindDirection.CALM: -1,
WindDirection.N: 0,
WindDirection.NE: 45,
WindDirection.E: 90,
WindDirection.SE: 135,
WindDirection.S: 180,
WindDirection.SW: 225,
WindDirection.W: 270,
WindDirection.NW: 315,
}[direction]
)
class WeatherValue(Model):
date: datetime.datetime
sky: Sky = Field(default_factory=Sky)
wind: Wind = Field(default_factory=Wind)
temperature: list[int] = Field(default_factory=list)
precipitation: float = 0
pressure: list[int] = Field(default_factory=list)
humidity: int = -1
sky: Sky
temperature: list[int]
wind_speed: int
wind_gust: int
wind_direction: float
precipitation: float
pressure: list[int]
humidity: int
class WeatherResponse(Model):
location: str
date: datetime.date
period: Literal["day", "days"] = "day"
period: str
values: list[WeatherValue]
-49
View File
@@ -1,49 +0,0 @@
from gallery.sketch.parse.core import Parser
from ..model import WindDirection
class WindDirectionParser(Parser[str | int | float, WindDirection | None]):
WIND_DIRECTION_MAP: dict[str, WindDirection | None] = {
"с": WindDirection.N,
"св": WindDirection.NE,
"в": WindDirection.E,
"юв": WindDirection.SE,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
# pylint:disable=too-many-return-statements
def _parse_number(self, value: int | float) -> WindDirection | None:
if value > 337.5 or value <= 22.25:
return WindDirection.N
elif value <= 67.5:
return WindDirection.NE
elif value <= 112.5:
return WindDirection.E
elif value <= 157.5:
return WindDirection.SE
elif value <= 202.5:
return WindDirection.S
elif value <= 247.5:
return WindDirection.SW
elif value <= 292.5:
return WindDirection.W
elif value <= 337.5:
return WindDirection.NW
else:
return None
def _parse_str(self, value: str) -> WindDirection | None:
return self.WIND_DIRECTION_MAP.get(value.replace("-", "").lower())
def parse(self, data: str | int | float) -> WindDirection | None:
if isinstance(data, str):
return self._parse_str(data)
else:
return self._parse_number(data)
WIND_DIRECTION_PARSER = WindDirectionParser()
+32 -48
View File
@@ -1,28 +1,30 @@
import datetime
import statistics
from typing import Counter, Literal, overload
from .model import Cloudness, Precipitation, WeatherResponse, WeatherValue
MergeMode = Literal["interval", "average"]
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
def _merge_values(values: list[int], mode: MergeMode) -> list[int]:
if mode == "interval":
if (max_value := max(values)) == (min_value := min(values)):
return [max_value]
else:
return [max_value, min_value]
else:
return [round(sum(values) / len(values))]
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=WindDirectionDeg(-1),
precipitation=0,
pressure=[],
humidity=0,
)
def merge_weather_values(
date: datetime.datetime,
values: list[WeatherValue],
mode: MergeMode = "interval",
) -> WeatherValue:
result = WeatherValue(date=date)
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
result = build_weather_value(date)
temperatures = []
pressures = []
humidities = []
@@ -36,43 +38,25 @@ def merge_weather_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)
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 = _merge_values(temperatures, mode)
result.pressure = _merge_values(pressures, mode)
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 = Counter(wind_directions).most_common(1)[0][0]
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:
# TODO: Instance of 'FieldInfo' has no 'cloudness' member (no-member)
# pylint:disable=no-member
if Cloudness.values.index(item) > Cloudness.values.index(result.sky.cloudness):
if item != Cloudness.CLEAR:
result.sky.cloudness = item
# TODO: merge precipitations
for item in precipitations:
# TODO: Instance of 'FieldInfo' has no 'precipitation' member (no-member)
# pylint:disable=no-member
if Precipitation.values.index(item) > Precipitation.values.index(result.sky.precipitation):
if item != Precipitation.NO:
result.sky.precipitation = item
result.precipitation = precipitation
return result
def convert_hPa_to_mmHg(value: int) -> int:
return round(value / 1.333)
@overload
def fix_dates_sequence(items: list[WeatherValue]): ...
@overload
def fix_dates_sequence(items: list[WeatherResponse]): ...
def fix_dates_sequence(items):
date = None
for item in items:
if date and (item.date - date).total_seconds() < 0:
item.date = date + datetime.timedelta(days=1)
date = item.date
+1 -1
View File
@@ -1,4 +1,4 @@
import tomllib
from pathlib import Path
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["project"]["version"]
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]
-9
View File
@@ -69,18 +69,9 @@ msgstr "Давление, мм рт. ст."
msgid "Humidity, %"
msgstr "Влажность, %"
msgid "Stored locations"
msgstr "Сохранённые локации"
# tv
msgid "TV program"
msgstr "Телепрограмма"
msgid "Live broadcasts"
msgstr "Прямые трансляции"
msgid "Stored channels"
msgstr "Сохранённые каналы"
msgid "Channel groups"
msgstr "Группы каналов"
Generated
+85 -392
View File
@@ -192,10 +192,9 @@ frozenlist = ">=1.1.0"
name = "annotated-doc"
version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
@@ -207,7 +206,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -217,10 +216,9 @@ files = [
name = "anyio"
version = "4.14.1"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"},
{file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"},
@@ -260,10 +258,9 @@ files = [
name = "babel"
version = "2.18.0"
description = "Internationalization utilities"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"},
{file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"},
@@ -350,10 +347,9 @@ uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ;
name = "certifi"
version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = true
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
@@ -365,12 +361,11 @@ version = "8.4.2"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
groups = ["app", "dev"]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
]
markers = {main = "extra == \"app\""}
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
@@ -381,146 +376,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev", "test"]
groups = ["app", "dev", "test"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "extra == \"app\" and (platform_system == \"Windows\" or sys_platform == \"win32\")", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
[[package]]
name = "coverage"
version = "7.16.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["test"]
files = [
{file = "coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853"},
{file = "coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097"},
{file = "coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a"},
{file = "coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881"},
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43"},
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b"},
{file = "coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec"},
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9"},
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38"},
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a"},
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594"},
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773"},
{file = "coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc"},
{file = "coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90"},
{file = "coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da"},
{file = "coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07"},
{file = "coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5"},
{file = "coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475"},
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567"},
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e"},
{file = "coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d"},
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b"},
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf"},
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01"},
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08"},
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572"},
{file = "coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2"},
{file = "coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7"},
{file = "coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22"},
{file = "coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af"},
{file = "coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c"},
{file = "coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809"},
{file = "coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf"},
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f"},
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360"},
{file = "coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08"},
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941"},
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489"},
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776"},
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6"},
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0"},
{file = "coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d"},
{file = "coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e"},
{file = "coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8"},
{file = "coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9"},
{file = "coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245"},
{file = "coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5"},
{file = "coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5"},
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f"},
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41"},
{file = "coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878"},
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2"},
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080"},
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037"},
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b"},
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0"},
{file = "coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a"},
{file = "coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa"},
{file = "coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d"},
{file = "coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15"},
{file = "coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57"},
{file = "coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3"},
{file = "coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc"},
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751"},
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab"},
{file = "coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9"},
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412"},
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a"},
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540"},
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9"},
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361"},
{file = "coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b"},
{file = "coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b"},
{file = "coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f"},
{file = "coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152"},
{file = "coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb"},
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741"},
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1"},
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72"},
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8"},
{file = "coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c"},
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a"},
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da"},
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2"},
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e"},
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae"},
{file = "coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2"},
{file = "coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d"},
{file = "coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6"},
{file = "coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743"},
{file = "coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e"},
{file = "coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae"},
{file = "coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370"},
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae"},
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de"},
{file = "coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1"},
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c"},
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6"},
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f"},
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df"},
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040"},
{file = "coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827"},
{file = "coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d"},
{file = "coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855"},
{file = "coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6"},
{file = "coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d"},
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6"},
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1"},
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1"},
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b"},
{file = "coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de"},
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef"},
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876"},
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8"},
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220"},
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0"},
{file = "coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5"},
{file = "coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db"},
{file = "coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0"},
{file = "coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4"},
{file = "coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
markers = {app = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
[[package]]
name = "dateparser"
@@ -548,10 +409,9 @@ langdetect = ["langdetect (>=1.0.0)"]
name = "detect-installer"
version = "0.1.0"
description = "Detect how a Python package was installed and get the correct upgrade command"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a"},
{file = "detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7"},
@@ -577,10 +437,9 @@ profile = ["gprof2dot (>=2022.7.29)"]
name = "dnspython"
version = "2.8.0"
description = "DNS toolkit"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
{file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
@@ -599,10 +458,9 @@ wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
name = "email-validator"
version = "2.3.0"
description = "A robust email address syntax and deliverability validation library."
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
{file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
@@ -616,10 +474,9 @@ idna = ">=2.0.0"
name = "fastapi"
version = "0.139.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189"},
{file = "fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145"},
@@ -650,10 +507,9 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand
name = "fastapi-cli"
version = "0.0.27"
description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "fastapi_cli-0.0.27-py3-none-any.whl", hash = "sha256:2e389a40f318e29fec8cb1e289f267f17c048876fb82dbfa869a10b16740495d"},
{file = "fastapi_cli-0.0.27.tar.gz", hash = "sha256:1dffb1e40c0c88f2e0171a8a252a2b615c1e63ff8c05626649e4badd6a84336a"},
@@ -674,10 +530,9 @@ standard-no-fastapi-cloud-cli = ["uvicorn[standard] (>=0.15.0)"]
name = "fastapi-cloud-cli"
version = "0.22.0"
description = "Deploy and manage FastAPI Cloud apps from the command line 🚀"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "fastapi_cloud_cli-0.22.0-py3-none-any.whl", hash = "sha256:799e263140d1b6e679390fc6e90da404556aee6730fd479d3b52475e850073bf"},
{file = "fastapi_cloud_cli-0.22.0.tar.gz", hash = "sha256:cde14470227fc9275075ff537977d2991e8ce1baf947055d7a4149726de27e08"},
@@ -701,10 +556,9 @@ standard = ["uvicorn[standard] (>=0.15.0)"]
name = "fastar"
version = "0.11.0"
description = "High-level bindings for the Rust tar crate"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "fastar-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7c906ad371ca365591ebcb7630009923f3eceb20956814494d15591a78e9e46"},
{file = "fastar-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6919497b35fa5bd978d2c26ee117cf1771b90ee5073f7518e44b9bc364b57715"},
@@ -987,107 +841,13 @@ files = [
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
]
[[package]]
name = "greenlet"
version = "3.5.5"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638"},
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0"},
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4"},
{file = "greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc"},
{file = "greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f"},
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db"},
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39"},
{file = "greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53"},
{file = "greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5"},
{file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759"},
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b"},
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2"},
{file = "greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18"},
{file = "greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52"},
{file = "greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b"},
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537"},
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e"},
{file = "greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd"},
{file = "greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc"},
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -1097,10 +857,9 @@ files = [
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -1120,10 +879,9 @@ trio = ["trio (>=0.22.0,<1.0)"]
name = "httptools"
version = "0.8.0"
description = "A collection of framework independent HTTP protocol utils."
optional = true
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826"},
{file = "httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77"},
@@ -1181,10 +939,9 @@ files = [
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -1209,7 +966,7 @@ version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@@ -1249,10 +1006,9 @@ colors = ["colorama"]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = true
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@@ -1268,10 +1024,9 @@ i18n = ["Babel (>=2.7)"]
name = "markdown-it-py"
version = "4.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
@@ -1293,10 +1048,9 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-tim
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = true
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@@ -1405,10 +1159,9 @@ files = [
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = true
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -1623,28 +1376,6 @@ files = [
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
]
[[package]]
name = "playwright"
version = "1.62.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f"},
{file = "playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034"},
{file = "playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da"},
{file = "playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3"},
{file = "playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff"},
{file = "playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c"},
{file = "playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1"},
{file = "playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1"},
]
[package.dependencies]
greenlet = ">=3.1.1,<4.0.0"
pyee = ">=13,<14"
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1798,7 +1529,7 @@ version = "2.13.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
{file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
@@ -1821,7 +1552,7 @@ version = "2.46.4"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
@@ -1952,10 +1683,9 @@ typing-extensions = ">=4.14.1"
name = "pydantic-extra-types"
version = "2.11.1"
description = "Extra Pydantic types."
optional = true
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1"},
{file = "pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049"},
@@ -1979,10 +1709,9 @@ uuid-utils = ["uuid-utils (>=0.6.0) ; python_version < \"3.14\""]
name = "pydantic-settings"
version = "2.14.2"
description = "Settings management using Pydantic"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"},
{file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"},
@@ -2000,36 +1729,17 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pyee"
version = "13.0.1"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["main", "test"]
groups = ["app", "test"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
markers = {main = "extra == \"app\""}
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
@@ -2119,10 +1829,9 @@ six = ">=1.5"
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
@@ -2135,10 +1844,9 @@ cli = ["click (>=5.0)"]
name = "python-multipart"
version = "0.0.32"
description = "A streaming multipart parser for Python"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"},
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
@@ -2215,10 +1923,9 @@ files = [
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -2443,10 +2150,9 @@ files = [
name = "rich"
version = "15.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = true
optional = false
python-versions = ">=3.9.0"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
@@ -2463,10 +2169,9 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
name = "rich-toolkit"
version = "0.20.1"
description = "Rich toolkit for building command-line applications"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "rich_toolkit-0.20.1-py3-none-any.whl", hash = "sha256:2a6d5f8e15759b9eba5a9ee63da10b275359ead20e5a0fc92bd5b4dbae8ce4bf"},
{file = "rich_toolkit-0.20.1.tar.gz", hash = "sha256:c7336ae281f435c785acecaedc4b71d4b663dc73d9c8079fea96372527e822a4"},
@@ -2481,10 +2186,9 @@ typing-extensions = ">=4.12.2"
name = "rignore"
version = "0.7.6"
description = "Python Bindings for the ignore crate"
optional = true
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45"},
{file = "rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec"},
@@ -2646,10 +2350,9 @@ files = [
name = "sentry-sdk"
version = "2.64.0"
description = "Python client for Sentry (https://sentry.io)"
optional = true
optional = false
python-versions = ">=3.6"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1"},
{file = "sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55"},
@@ -2711,10 +2414,9 @@ unleash = ["UnleashClient (>=6.0.1)"]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = true
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
@@ -2748,10 +2450,9 @@ files = [
name = "starlette"
version = "1.3.1"
description = "The little ASGI library that shines."
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"},
{file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"},
@@ -2779,10 +2480,9 @@ files = [
name = "typer"
version = "0.26.8"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c"},
{file = "typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e"},
@@ -2800,7 +2500,7 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -2812,7 +2512,7 @@ version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "app"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
@@ -2857,10 +2557,9 @@ testing = ["check_manifest", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-m
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
@@ -2876,10 +2575,9 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
name = "uvicorn"
version = "0.49.0"
description = "The lightning-fast ASGI server."
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f"},
{file = "uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"},
@@ -2903,10 +2601,10 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.8.0)
name = "uvloop"
version = "0.22.1"
description = "Fast implementation of asyncio event loop on top of libuv"
optional = true
optional = false
python-versions = ">=3.8.1"
groups = ["main"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"app\""
groups = ["app"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
files = [
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"},
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
@@ -2968,10 +2666,9 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil",
name = "watchfiles"
version = "1.2.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9"},
{file = "watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4"},
@@ -3089,10 +2786,9 @@ anyio = ">=3.0.0"
name = "websockets"
version = "16.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = true
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"app\""
groups = ["app"]
files = [
{file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
{file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
@@ -3276,10 +2972,7 @@ idna = ">=2.0"
multidict = ">=4.0"
propcache = ">=0.2.1"
[extras]
app = ["babel", "fastapi", "jinja2"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<4.0"
content-hash = "1494c77fcc28f6899d0fd2101647cb880e793805898472a96e2a66b125dd6c72"
python-versions = "^3.14"
content-hash = "16dac910366fb78d67f45bac4c8cf19c0f448cb53fccb7e33c13ea22d509783f"
+31 -33
View File
@@ -1,44 +1,42 @@
[project]
name = "gallery"
description = ""
version = "0.10.2"
readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = '>=3.14,<4.0'
dependencies = [
'aiohttp (>=3.14,<4.0)',
'beautifulsoup4 (>=4.15,<5.0)',
'dateparser (>=1.4,<2.0)',
'pydantic (>=2.13,<3.0)',
'aiocache[redis] (>=0.12,<0.13)',
"playwright (>=1.62.0,<2.0.0)",
]
[project.optional-dependencies]
app = [
"fastapi[standard] (>=0.139,<0.140)",
"jinja2 (>=3.1,<4.0)",
"babel (>=2.18,<3.0)",
]
[dependency-groups]
test = ["pytest (>=9.1,<10.0)", "pytest-asyncio (>=1.4,<2.0)", "coverage (>=7.16.1,<8.0.0)"]
dev = ["pylint (>=4.0.6,<5.0.0)", "black (>=26.5,<27.0)", "isort (>=8.0,<9.0)"]
[project.scripts]
gallery = "gallery.main:run"
[tool.poetry]
name = "gallery"
version = "0.5.2"
description = ""
authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md"
packages = [{ include = "gallery" }]
requires-poetry = '>=2.0.0,<3.0.0'
[tool.poetry.dependencies]
python = "^3.14"
aiohttp = "^3.14"
beautifulsoup4 = "^4.15"
dateparser = "^1.4"
pydantic = "^2.13"
aiocache = { extras = ["redis"], version = "^0.12" }
[tool.poetry.group.app.dependencies]
fastapi = {extras = ["standard"], version = "^0.139.0"}
jinja2 = "^3.1"
babel = "^2.18"
[tool.poetry.group.test.dependencies]
pytest = "^9.1"
pytest-asyncio = "^1.4"
[tool.poetry.group.dev.dependencies]
pylint = "^4.0"
black = "^26.5"
isort = "^8.0"
[build-system]
requires = ['poetry-core (>=2.0.0,<3.0.0)']
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
gallery = "gallery.main:run"
[tool.black]
line-length = 120
target-version = ["py314"]
[tool.isort]
profile = "black"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker compose -f docker-compose-develop.yaml up --build --watch
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
. .env
build () {
echo "build: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
ARGS=("build")
for ARG in ${DOCKER_ARGS[@]}; do
ARGS+=("--build-arg" "$ARG")
done
if [ -n "$PROJECT_TARGET" ]; then
ARGS+=("--target" "$PROJECT_TARGET")
fi
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
ARGS+=("-f" "$1/Dockerfile")
echo "${ARGS[@]}"
docker "${ARGS[@]}"
done
}
publish () {
echo "publish: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:latest
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
done
}
save () {
echo "save: $1"
. "$1/.env"
mkdir -p "$1/dist"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker save --output "$1/dist/$PROJECT_NAME-$VERSION.tar" "$DOCKER_GROUP/$PROJECT_NAME"
done
}
DEFAULT_TARGETS="."
TARGETS="${@-$DEFAULT_TARGETS}"
DOCKER_ACTION="${DOCKER_ACTION-build}"
for TARGET in $TARGETS; do
$DOCKER_ACTION "$TARGET"
done
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run isort $TARGET
poetry run black $TARGET -q
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run pylint $TARGET -sn
poetry run isort $TARGET --check-only
poetry run black $TARGET -q --check --diff
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
cd locales/ru/LC_MESSAGES || exit
msgfmt messages.po
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
# env
if [[ ! -f .env ]]; then
cp .env-base .env
fi
source .env
# python
poetry env use ${PYTHON_VERSION}
poetry install
# static
cd static || exit
if [[ -f $HOME/.nvm/nvm.sh ]]; then
source "$HOME/.nvm/nvm.sh"
nvm use
fi
npm ci
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
OPENWEATHER_KEY="" poetry run pytest tests
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
if [ -z "$1" ]; then
echo "Usage: $0 [version]"
exit 1
fi
if [[ ! -z "$(git status -s)" ]]; then
echo "Uncomitted changes"
exit 1
fi
poetry version $1
(cd static && npm version $1 --allow-same-version)
git add .
git commit -m "ci(version): $1"
git tag $1
+34 -2168
View File
File diff suppressed because it is too large Load Diff
+2 -13
View File
@@ -1,6 +1,6 @@
{
"name": "gallery",
"version": "0.10.2",
"version": "0.5.2",
"type": "module",
"scripts": {
"build": "webpack --mode production",
@@ -16,19 +16,12 @@
"flag-icons": "^7.5.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@types/bootstrap": "^5.2.11",
"@types/node": "^26.1.0",
"autoprefixer": "^10.5.4",
"babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"core-js": "^3.49.0",
"css-loader": "^7.1.4",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.10.2",
"postcss": "^8.5.22",
"postcss-loader": "^8.2.1",
"sass": "^1.101.0",
"sass-loader": "^17.0.0",
"style-loader": "^4.0.0",
@@ -37,9 +30,5 @@
"typescript": "^6.0.3",
"webpack": "^5.108.3",
"webpack-cli": "^7.1.0"
},
"browserslist": [
"> 0.5%",
"last 2 versions"
]
}
}
-196
View File
@@ -1,196 +0,0 @@
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export abstract class ValueElement<T, O> extends HTMLElement {
static observedAttributes = ["value"];
private _value: T | null = null;
get value(): T | null {
return this._value;
}
set value(value: T | null) {
if (value) {
this.setAttribute("value", JSON.stringify(value));
} else if (this.hasAttribute("value")) {
this.removeAttribute("value");
}
}
options: O | null;
constructor(value: T | null = null, options: O | null = null) {
super();
if (value) {
this.value = value;
}
this.options = options;
this.onInit();
}
onInit() {}
beforeRefresh() {}
onRefresh() {}
abstract buildHTML(): string;
refresh() {
const rawValue = this.getAttribute("value");
if (rawValue) {
this._value = JSON.parse(rawValue);
} else {
this._value = null;
}
if (this.className) {
this.classList = this.className;
}
this.beforeRefresh();
this.innerHTML = this.buildHTML();
this.onRefresh();
}
connectedCallback() {
this.refresh();
}
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
setTimeout(() => this.refresh());
}
}
export interface ListOptions<T> {
onClick?: (element: ListItemElement<T>) => void;
onAdd?: (element: ListItemElement<T>) => void;
onRemove?: (element: ListItemElement<T>) => void;
}
export abstract class ListItemElement<T> extends ValueElement<T, ListOptions<T>> {
abstract getHref(value: T): string;
abstract getContent(value: T): string;
className = "list-group-item list-group-item-action";
onInit(): void {
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
this.handleAddClick = this.handleAddClick.bind(this);
}
buildHTML(): string {
if (!this.value) {
return "";
}
const href = this.getHref(this.value);
const content = this.getContent(this.value);
return `
<a href="${href}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-success me-2 ${this.options?.onAdd ? "" : "d-none"}" style="color: green;" data-add>
&plus;
</span>
${content}
</span>
<span>
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
&#x2715;
</span>
</span>
</a>
`;
}
onRefresh(): void {
this.addEventListener("click", this.handleClick);
if (this.options?.onAdd) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
if (this.options?.onRemove) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
handleClick(event: MouseEvent) {
if (this.value && this.options?.onClick) {
this.options.onClick(this);
}
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onAdd) {
this.options.onAdd(this);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onRemove) {
this.options.onRemove(this);
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-add]")?.removeEventListener("click", this.handleAddClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
}
export abstract class ListElement<T, C extends ListItemElement<T> = ListItemElement<T>> extends ValueElement<
T[],
ListOptions<T>
> {
static observedAttributes = ["value", "onadd", "onremove", "onclick"];
abstract childType: Type<C>;
className = "list-group";
buildHTML(): string {
return "";
}
beforeRefresh() {
if (this.hasAttribute("onclick")) {
const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions<T>["onClick"];
if (!this.options) {
this.options = {};
}
this.options.onClick = onclick;
}
if (this.hasAttribute("onadd")) {
const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions<T>["onAdd"];
if (!this.options) {
this.options = {};
}
this.options.onAdd = onadd;
}
if (this.hasAttribute("onremove")) {
const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions<T>["onRemove"];
if (!this.options) {
this.options = {};
}
this.options.onRemove = onremove;
}
}
onRefresh() {
if (this.value) {
for (const item of this.value) {
this.appendChild(new this.childType(item, this.options));
}
}
}
}
-50
View File
@@ -1,50 +0,0 @@
import { ListElement, ListItemElement } from "./component";
import { DataStorage, StorageValue } from "./storage";
export class DataManager<T extends StorageValue> {
private storage: DataStorage<T>;
private get dataList(): ListElement<T> {
return document.querySelector(this.dataListSelector)!;
}
constructor(
listComponentType: typeof ListElement<T>, //prevent removing as unusing
storageKey: string,
private dataListSelector: string,
) {
this.storage = new DataStorage(storageKey);
this.load = this.load.bind(this);
this.store = this.store.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
}
load() {
this.dataList.value = this.storage.getAll();
}
store(element: ListItemElement<T>) {
if (element.value) {
this.storage.add(element.value);
}
}
add(element: ListItemElement<T>) {
const value = element.value;
value;
if (value && this.storage.add(value)) {
const clone: ListItemElement<T> = element.cloneNode(true) as ListItemElement<T>;
clone.options = { onRemove: this.remove };
this.dataList.appendChild(clone);
}
}
remove(element: ListItemElement<T>) {
const value = element.value;
if (value) {
this.storage.remove(value.id);
element.remove();
}
}
}
-46
View File
@@ -1,46 +0,0 @@
export interface StorageValue {
id: string;
}
export class DataStorage<T extends StorageValue> {
constructor(private storageKey: string) {}
private readValues(): T[] {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "[]");
if (!Array.isArray(objects)) {
return Object.values(objects);
}
return objects;
}
private writeValues(values: T[]) {
window.localStorage.setItem(this.storageKey, JSON.stringify(values));
}
getAll(): T[] {
return this.readValues();
}
add(value: T): boolean {
const values = this.readValues();
for (const v of values) {
if (v.id == value.id) {
return false;
}
}
values.push(value);
this.writeValues(values);
return true;
}
remove(id: string) {
const values = this.readValues();
for (let i = 0; i < values.length; i++) {
if (values[i].id == id) {
values.splice(i, 1);
this.writeValues(values);
return;
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
export interface Channel {
id: string;
name: string;
provider: string;
}
export class ScheduleChannelStorage {
private storageKey: string = "schedule:channels";
getAll(): Channel[] {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(objects);
}
add(object: Channel) {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
objects[object.id] = object;
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
}
remove(id: string) {
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete objects[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
}
}
export class ScheduleChannelManager {
loadChannels(selector: string) {
const channels = window.scheduleChannelStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const channel of channels) {
const element = new ScheduleChannelElement();
element.setAttribute("channel", JSON.stringify(channel));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class ScheduleChannelElement extends HTMLElement {
static observedAttributes = ["channel", "removable"];
channel: Channel | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.channel = JSON.parse(this.getAttribute("channel") || "{}");
this.innerHTML = `
<a href="${this.channel?.provider}/${this.channel?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-primary">${this.channel?.name}</span>
</span>
<span>
<span class="badge text-bg-secondary">${this.channel?.provider}</span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.channel) {
window.scheduleChannelStorage.add(this.channel);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
window.scheduleChannelStorage.remove(this.channel.id);
}
this.remove();
}
}
customElements.define("schedule-channel", ScheduleChannelElement);
declare global {
interface Window {
scheduleChannelStorage: ScheduleChannelStorage;
scheduleChannelManager: ScheduleChannelManager;
}
}
window.scheduleChannelStorage = new ScheduleChannelStorage();
window.scheduleChannelManager = new ScheduleChannelManager();
-52
View File
@@ -1,52 +0,0 @@
import { ListElement, ListItemElement, ValueElement } from "../base/component";
import { Channel } from "./model";
export class ScheduleChannelGroup extends ValueElement<string | Channel[], {}> {
className: string = "list-group-item list-group-item-action";
buildHTML(): string {
const groupName = Array.isArray(this.value) ? "Open All" : this.value;
const groupId = Array.isArray(this.value)
? this.value.map((item) => `${item.provider}:${item.id}`).join(",")
: this.value || "";
return `
<a href="group/${encodeURIComponent(groupId)}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-primary" style="text-transform: capitalize">${groupName}</span>
</span>
</a>`;
}
}
customElements.define("schedule-channel-group", ScheduleChannelGroup);
export class ScheduleChannelItem extends ListItemElement<Channel> {
getHref(value: Channel): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Channel): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="text-primary">${value.name}</span>
`;
}
}
export class ScheduleChannelList extends ListElement<Channel, ScheduleChannelItem> {
childType = ScheduleChannelItem;
public onRefresh(): void {
super.onRefresh();
if (this.value?.length) {
const groupElement = new ScheduleChannelGroup(this.value);
this.prepend(groupElement);
} else {
this.prepend("Empty");
}
}
}
customElements.define("schedule-channel-item", ScheduleChannelItem);
customElements.define("schedule-channel-list", ScheduleChannelList);
-11
View File
@@ -1,11 +0,0 @@
import { DataManager } from "../base/manager";
import { ScheduleChannelList } from "./component";
import { Channel } from "./model";
declare global {
interface Window {
scheduleChannelManager: DataManager<Channel>;
}
}
window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels");
-5
View File
@@ -1,5 +0,0 @@
export interface Channel {
id: string;
name: string;
provider: string;
}
+118
View File
@@ -0,0 +1,118 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
export class WeatherLocationStorage {
private storageKey: string = "weather:locations";
getAll(): Location[] {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(locations);
}
add(location: Location) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
locations[location.id] = location;
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
remove(id: string) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete locations[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
}
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class WeatherLocationElement extends HTMLElement {
static observedAttributes = ["location", "removable"];
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.location = JSON.parse(this.getAttribute("location") || "{}");
this.innerHTML = `
<a href="${this.location?.provider}/${this.location?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
</span>
</span>
<span>
<span class="badge text-bg-secondary">${this.location?.provider}</span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = 'list-group-item list-group-item-action';
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
}
}
customElements.define("weather-location", WeatherLocationElement);
declare global {
interface Window {
weatherLocationStorage: WeatherLocationStorage;
weatherLocationManager: WeatherLocationManager;
}
}
window.weatherLocationStorage = new WeatherLocationStorage();
window.weatherLocationManager = new WeatherLocationManager();
-33
View File
@@ -1,33 +0,0 @@
import { ListElement, ListItemElement } from "../base/component";
import { Location } from "./model";
export class WheatherLocationItem extends ListItemElement<Location> {
getHref(value: Location): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Location): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="fi fi-${value.country_code} me-1"></span>
<span class="text-primary">${value.name}</span>
<span class="small ms-1 text-secondary">
${value.country}, ${value.district}, ${value.subdistrict}
</span>
`;
}
}
export class WeatherLocationList extends ListElement<Location, WheatherLocationItem> {
childType = WheatherLocationItem;
public onRefresh(): void {
super.onRefresh();
if (!this.value?.length) {
this.prepend("Empty");
}
}
}
customElements.define("weather-location-item", WheatherLocationItem);
customElements.define("weather-location-list", WeatherLocationList);
-11
View File
@@ -1,11 +0,0 @@
import { DataManager } from "../base/manager";
import { WeatherLocationList } from "./component";
import { Location } from "./model";
declare global {
interface Window {
weatherLocationManager: DataManager<Location>;
}
}
window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");
-9
View File
@@ -1,9 +0,0 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
-3
View File
@@ -45,7 +45,4 @@
&.bi-file-earmark-text {
mask-image: url(bootstrap-icons/icons/file-earmark-text.svg);
}
&.bi-cast {
mask-image: url(bootstrap-icons/icons/cast.svg);
}
}
@@ -1,4 +1,3 @@
# pylint: skip-file
from pathlib import Path
from lxml import etree
@@ -4,12 +4,8 @@ weather-icon,
.wi-svg {
display: flex;
width: 3rem;
height: 3rem;
> svg {
width: 3rem;
height: 3rem;
path {
fill: gray;
}
@@ -33,20 +29,8 @@ weather-icon,
&.wi-s {
width: 2.5rem;
height: 2.5rem;
> svg {
width: 2.5rem;
height: 2.5rem;
}
}
&.wi-l {
width: 3.5rem;
height: 3.5rem;
> svg {
width: 3.5rem;
height: 3.5rem;
}
}
}
+5 -13
View File
@@ -9,34 +9,26 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config: webpack.Configuration = {
target: ["web", "es6"],
entry: ["./src/index.ts", "./src/main.scss"],
module: {
rules: [
{ test: /\.svg$/, use: ["file-loader"] },
{
test: /\.tsx?$/,
use: [{ loader: "babel-loader" }, { loader: "ts-loader" }],
use: {
loader: "ts-loader",
},
exclude: /node_modules/,
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: false, url: false } },
{
loader: "postcss-loader",
options: {
sourceMap: false,
postcssOptions: {
plugins: [["autoprefixer", { grid: "autoplace" }]],
},
},
},
{ loader: "css-loader", options: { sourceMap: true, url: false } },
{
loader: "sass-loader",
options: {
sourceMap: false,
sourceMap: true,
sassOptions: { quietDeps: true, silenceDeprecations: ["color-functions", "global-builtin", "import"] },
},
},

Some files were not shown because too many files have changed in this diff Show More