Compare commits
8
Commits
beafed8faf
..
0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d54e0afd0 | ||
|
|
292cdd6037 | ||
|
|
01d0d1eeb8 | ||
|
|
b85e9ac5f3 | ||
|
|
8f244455ac | ||
|
|
820472796b | ||
|
|
d1bdac66c0 | ||
|
|
d21e31ca35 |
+1
-1
@@ -10,4 +10,4 @@ max_line_length = 120
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = true
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -1,75 +1,13 @@
|
||||
# API Gallery
|
||||
|
||||
Currently implemented APIs:
|
||||
Weather and TV program API
|
||||
|
||||
- Weather forecast
|
||||
- TV program
|
||||

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

|
||||
## Swagger
|
||||
|
||||
https://api.shmyga.ru/docs
|
||||
|
||||
@@ -13,7 +13,6 @@ services:
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||
- GISMETEO_AUTH=$GISMETEO_AUTH
|
||||
- DEBUG=1
|
||||
ports:
|
||||
- 8000:80
|
||||
|
||||
+4
-5
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> <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> <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>
|
||||
|
||||
@@ -1 +1,101 @@
|
||||
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, 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,
|
||||
},
|
||||
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))
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import datetime
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from gallery.sketch.weather.model import (
|
||||
Cloudness,
|
||||
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]:
|
||||
@@ -55,25 +54,3 @@ def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
||||
main_icon = f"{day_prefix}-{main_icon}"
|
||||
icons = [main_icon]
|
||||
return icons
|
||||
|
||||
|
||||
class WeatherIconBundle:
|
||||
def __init__(self):
|
||||
self._icon_path = root_path / "static/dist/weather-icons/icons"
|
||||
self._cache = {}
|
||||
|
||||
def _load_icon(self, icon: str) -> Markup:
|
||||
icon_file = self._icon_path / f"wi-{icon}.svg"
|
||||
return Markup(icon_file.read_text())
|
||||
|
||||
def get(self, icon: str) -> Markup:
|
||||
if icon not in self._cache:
|
||||
self._cache[icon] = self._load_icon(icon)
|
||||
return self._cache[icon]
|
||||
|
||||
|
||||
WEATHER_ICON_BUNDLE = WeatherIconBundle()
|
||||
|
||||
|
||||
def weather_icon_svg(icon: str) -> Markup:
|
||||
return WEATHER_ICON_BUNDLE.get(icon)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.datastructures import URL
|
||||
|
||||
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(request: AppRequest, days: int):
|
||||
url = URL(path=f"../days/{days}", query=request.url.query)
|
||||
return RedirectResponse(url)
|
||||
|
||||
|
||||
# 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>
|
||||
@@ -71,8 +66,8 @@ locale=request.state.language)}}{% endblock %}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.sky }}">
|
||||
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||
<div class="wi-svg wi-l wi-border"
|
||||
style="margin: 0 auto;">{{ icon | weather_icon_svg }}</div>
|
||||
<!--<div class="wi wi-{{icon}} wi-xl text-primary"></div>-->
|
||||
<weather-icon icon="{{icon}}"></svg-icon>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
@@ -107,9 +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}}"
|
||||
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
|
||||
data-bs-title="{{ value.wind_direction }}">
|
||||
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
@@ -123,11 +117,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 +171,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
@@ -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 +0,0 @@
|
||||
PROVIDER = "gismeteo"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
PROVIDER = "matchtv"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
PROVIDER = "openweather"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
PROVIDER = "rp5"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 +0,0 @@
|
||||
PROVIDER = "yandex"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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,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
@@ -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,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]:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -1,44 +1,42 @@
|
||||
[project]
|
||||
name = "gallery"
|
||||
description = ""
|
||||
version = "0.10.3"
|
||||
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.0"
|
||||
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"
|
||||
|
||||
Executable
+5
@@ -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
|
||||
Executable
+55
@@ -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
@@ -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
@@ -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
|
||||
Executable
+6
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
OPENWEATHER_KEY="" poetry run pytest tests
|
||||
Executable
+19
@@ -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
|
||||
Generated
+34
-2168
File diff suppressed because it is too large
Load Diff
+2
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.10.3",
|
||||
"version": "0.5.0",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+
|
||||
</span>
|
||||
${content}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
|
||||
✕
|
||||
</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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
✕
|
||||
</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();
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
@@ -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>
|
||||
✕
|
||||
</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();
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface Location {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
country_code: string;
|
||||
country?: string;
|
||||
district?: string;
|
||||
subdistrict?: string;
|
||||
}
|
||||
Vendored
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -1,3 +1,7 @@
|
||||
@import "bootstrap/scss/mixins/banner";
|
||||
@include bsBanner("");
|
||||
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path id="wind" d="M3.74,14.5c0-2.04,0.51-3.93,1.52-5.66s2.38-3.1,4.11-4.11s3.61-1.51,5.64-1.51c1.52,0,2.98,0.3,4.37,0.89 s2.58,1.4,3.59,2.4s1.81,2.2,2.4,3.6s0.89,2.85,0.89,4.39c0,1.52-0.3,2.98-0.89,4.37s-1.4,2.59-2.4,3.59s-2.2,1.8-3.59,2.39 s-2.84,0.89-4.37,0.89c-1.53,0-3-0.3-4.39-0.89s-2.59-1.4-3.6-2.4s-1.8-2.2-2.4-3.58S3.74,16.03,3.74,14.5z M6.22,14.5 c0,2.37,0.86,4.43,2.59,6.18c1.73,1.73,3.79,2.59,6.2,2.59c1.58,0,3.05-0.39,4.39-1.18s2.42-1.85,3.21-3.2s1.19-2.81,1.19-4.39 s-0.4-3.05-1.19-4.4s-1.86-2.42-3.21-3.21s-2.81-1.18-4.39-1.18s-3.05,0.39-4.39,1.18S8.2,8.75,7.4,10.1S6.22,12.92,6.22,14.5z M11.11,20.35l3.75-13.11c0.01-0.1,0.06-0.15,0.15-0.15s0.14,0.05,0.15,0.15l3.74,13.11c0.04,0.11,0.03,0.19-0.02,0.25 s-0.13,0.06-0.24,0l-3.47-1.3c-0.1-0.04-0.2-0.04-0.29,0l-3.5,1.3c-0.1,0.06-0.17,0.06-0.21,0S11.09,20.45,11.11,20.35z"/>
|
||||
<path d="M3.74,14.5c0-2.04,0.51-3.93,1.52-5.66s2.38-3.1,4.11-4.11s3.61-1.51,5.64-1.51c1.52,0,2.98,0.3,4.37,0.89 s2.58,1.4,3.59,2.4s1.81,2.2,2.4,3.6s0.89,2.85,0.89,4.39c0,1.52-0.3,2.98-0.89,4.37s-1.4,2.59-2.4,3.59s-2.2,1.8-3.59,2.39 s-2.84,0.89-4.37,0.89c-1.53,0-3-0.3-4.39-0.89s-2.59-1.4-3.6-2.4s-1.8-2.2-2.4-3.58S3.74,16.03,3.74,14.5z M6.22,14.5 c0,2.37,0.86,4.43,2.59,6.18c1.73,1.73,3.79,2.59,6.2,2.59c1.58,0,3.05-0.39,4.39-1.18s2.42-1.85,3.21-3.2s1.19-2.81,1.19-4.39 s-0.4-3.05-1.19-4.4s-1.86-2.42-3.21-3.21s-2.81-1.18-4.39-1.18s-3.05,0.39-4.39,1.18S8.2,8.75,7.4,10.1S6.22,12.92,6.22,14.5z M11.11,20.35l3.75-13.11c0.01-0.1,0.06-0.15,0.15-0.15s0.14,0.05,0.15,0.15l3.74,13.11c0.04,0.11,0.03,0.19-0.02,0.25 s-0.13,0.06-0.24,0l-3.47-1.3c-0.1-0.04-0.2-0.04-0.29,0l-3.5,1.3c-0.1,0.06-0.17,0.06-0.21,0S11.09,20.45,11.11,20.35z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,3 @@
|
||||
# pylint: skip-file
|
||||
from pathlib import Path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user