15 Commits

Author SHA1 Message Date
c233b020fc ci(version): 0.2.1 2026-04-23 21:24:28 +03:00
869a8ae79f fix(gismeteo): fix optional subdistrict in gismeteo reponse 2026-04-23 21:24:13 +03:00
4c3b3aeafc ci(version): 0.2.0 2026-04-23 21:06:15 +03:00
d1592150fd build(scipts): add version script 2026-04-23 21:06:06 +03:00
9351b9f53a feat(easel): add localization 2026-04-23 15:47:16 +03:00
ecb574e286 feat(easel): add bootstrap 2026-04-22 21:42:39 +03:00
94870a5c86 feat(weather): add weather location search 2026-04-22 12:58:56 +03:00
3dd0a5410c docs: update README 2026-04-16 23:32:58 +03:00
a0e6f30e3b feat(schedule): update view 2026-04-16 23:05:41 +03:00
29fa6435ce feat(yandextv): add yandextv schedule api 2026-04-16 18:43:12 +03:00
a886322d0e test: fix tests 2026-04-14 22:40:10 +03:00
6112147b40 build(docker): update docker build 2026-04-14 09:22:07 +03:00
ad8144df37 feat(easel): add base template 2026-04-12 17:37:36 +03:00
f303d0e1f4 fix(gismeteo): fix gismeteo parser 2026-04-12 16:27:02 +03:00
3e80ccb0df feat(weather): add openweather api 2024-08-25 23:28:49 +03:00
108 changed files with 17410 additions and 12072 deletions

13
.editorconfig Normal file
View File

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

5
.env Normal file
View File

@@ -0,0 +1,5 @@
DOCKER_REPO=git.shmyga.ru
DOCKER_GROUP=infernalgames
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
DOCKER_PROJECTS=("gallery")

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
*.pyc *.pyc
*.mo
.pytest_cache .pytest_cache
.venv .venv
#.vscode #.vscode
static/node_modules
static/dist

View File

@@ -3,21 +3,31 @@ ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH" ENV PATH="$POETRY_HOME/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
COPY pyproject.toml poetry.lock ./ COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true RUN poetry config virtualenvs.in-project true
RUN poetry install --with app RUN poetry install --with app --no-root
FROM node:24 AS node-builder
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY static/package.json static/package-lock.json ./
RUN npm ci
COPY static ./
RUN npm run build
FROM python:3.12-slim FROM python:3.12-slim
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN apt update && \ RUN apt update && \
apt install -y locales && \ apt install -y locales gettext && \
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \ sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales dpkg-reconfigure --frontend=noninteractive locales
ENV LANG=ru_RU.UTF-8 ENV LANG=ru_RU.UTF-8
ENV LC_ALL=ru_RU.UTF-8 ENV LC_ALL=ru_RU.UTF-8
ENV TZ="Europe/Moscow" ENV TZ="Europe/Moscow"
COPY --from=builder /app ./ COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/ COPY gallery gallery/
RUN cd gallery/easel/route/view/locales/ru/LC_MESSAGES && msgfmt messages.po
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"] CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]

View File

@@ -1 +1,9 @@
# Gallery # API Gallery
## View
https://api.shmyga.ru
## Swagger
https://api.shmyga.ru/docs

View File

@@ -0,0 +1,25 @@
name: gallery
services:
redis:
container_name: gallery-redis
image: redis:alpine
stop_grace_period: 3s
volumes:
- redis_data:/data
app:
container_name: gallery-app-develop
build: .
environment:
- REDIS_HOST=redis
- DEBUG=1
ports:
- 8000:80
develop:
watch:
- action: sync
path: ./gallery
target: /app/gallery
volumes:
redis_data:

View File

@@ -1,3 +1,5 @@
name: gallery
services: services:
redis: redis:
container_name: gallery-redis container_name: gallery-redis
@@ -5,15 +7,15 @@ services:
stop_grace_period: 3s stop_grace_period: 3s
volumes: volumes:
- redis_data:/data - redis_data:/data
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
app: app:
container_name: gallery-app container_name: gallery-app
build: . image: ${DOCKER_ROOT}/gallery
# image: shmyga/gallery
environment: environment:
- REDIS_HOST=redis - REDIS_HOST=redis
depends_on:
- redis
ports: ports:
- 8000:80 - 127.0.0.1:8000:80
volumes: volumes:
redis_data: redis_data:

View File

@@ -1,19 +1,29 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": ".",
} },
], ],
"settings": { "settings": {
"python.testing.pytestArgs": ["tests", "-s"], "python.testing.pytestArgs": ["tests", "-s"],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:poetry",
"packageManager": "ms-python.python:poetry",
},
],
"files.associations": {
"*.html": "jinja-html",
},
"files.exclude": { "files.exclude": {
"**/__pycache__": true "**/__pycache__": true,
}, },
"terminal.integrated.env.linux": { "terminal.integrated.env.linux": {
"PYTHONPATH": "${workspaceFolder}" "PYTHONPATH": "${workspaceFolder}",
} },
}, },
"launch": { "launch": {
"version": "0.2.1", "version": "0.2.1",
@@ -23,13 +33,15 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "uvicorn", "module": "uvicorn",
"args": [ "args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
"gallery.main:app", },
"--reload", {
"--log-config", "name": "gallery:static",
"gallery/logging.yaml" "cwd": "${workspaceFolder}/static",
] "request": "launch",
} "type": "node-terminal",
] "command": "npm run dev",
} },
],
},
} }

View File

@@ -1,33 +1,27 @@
import locale as _locale import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.bundle import ApiBundle
from gallery.sketch.weather.api import WeatherApi from gallery.util import root_path
from .route import doc from .route import api, doc
from .route.api import schedule as schedule_api_route from .route.view import router as view_router
from .route.api import weather as weather_api_route
from .route.view import common as common_view_route DEFAULT_LOCALE = "ru_RU.UTF-8"
from .route.view import schedule as schedule_view_route
from .route.view import weather as weather_view_route
def build_app( def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale) _locale.setlocale(_locale.LC_TIME, locale)
app = FastAPI( app = FastAPI(
title="Gallery", title="Gallery",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
app.state.weather_api = weather_api app.state.api = api_bundle
app.state.schedule_api = schedule_api app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) doc.mount(app)
weather_api_route.mount(app) api.mount(app)
schedule_api_route.mount(app) app.include_router(view_router)
common_view_route.mount(app)
weather_view_route.mount(app)
schedule_view_route.mount(app)
return app return app

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

@@ -0,0 +1,15 @@
from fastapi import Request
from gallery.sketch.bundle import ApiBundle
class State:
api: ApiBundle
class App:
state: State
class AppRequest(Request):
app: App

View File

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

View File

@@ -1,5 +1,20 @@
import datetime
from fastapi import FastAPI from fastapi import FastAPI
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.model import ChannelId, Schedule
def mount(app: FastAPI): def mount(app: FastAPI):
pass @app.get("/api/schedule/channels", tags=["API"])
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channels()
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
async def get_api_schedule_channel_schedule(
request: AppRequest, channel: str, date: datetime.date
) -> Schedule:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channel_schedule(ChannelId(channel), date)

View File

@@ -1,27 +1,29 @@
import datetime import datetime
from fastapi import FastAPI, Request from fastapi import FastAPI
from gallery.sketch.weather.api import WeatherApi from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import Location, WeatherResponse
def mount(app: FastAPI): def mount(app: FastAPI):
@app.get("/api/weather/locations") @app.get("/api/weather/locations", tags=["API"])
async def get_api_weather_locations(request: Request) -> list[str]: async def get_api_weather_locations(
weather_api: WeatherApi = request.app.state.weather_api request: AppRequest, query: str
return await weather_api.get_locations() ) -> list[Location]:
weather_api = request.app.state.api.weather
return await weather_api.find_locations(query)
@app.get("/api/weather/{location}/day/{date}") @app.get("/api/weather/{location}/day/{date}", tags=["API"])
async def get_api_weather_day( async def get_api_weather_day(
request: Request, location: str, date: datetime.date request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse: ) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
return await weather_api.get_day(location, date) return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}") @app.get("/api/weather/{location}/days/{days}", tags=["API"])
async def get_api_weather_days( async def get_api_weather_days(
request: Request, location: str, days: int request: AppRequest, location: str, days: int
) -> WeatherResponse: ) -> WeatherResponse:
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days) return await weather_api.get_days(location, days)

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter, Depends
from .common import router as common_router
from .schedule import router as schedule_router
from .translation import set_language
from .weather import router as weather_router
router = APIRouter(dependencies=[Depends(set_language)])
router.include_router(common_router)
router.include_router(weather_router)
router.include_router(schedule_router)

View File

@@ -1,35 +1,39 @@
from pathlib import Path from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
from fastapi import FastAPI, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.version import __version__ from gallery.version import __version__
from ..translation import _
class Section(NamedTuple): class Section(NamedTuple):
link: str link: str
title: str title: str
icon: str
SECTIONS = [ SECTIONS = [
Section("weather", "Погода"), Section("weather", "Weather", "brightness-high"),
Section("schedule", "Телепрограмма"), Section("schedule", "TV program", "tv"),
] ]
base_dir = Path(__file__).parent
def mount(app: FastAPI): router = APIRouter()
base_dir = Path(__file__).parent
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
@app.get("/", response_class=HTMLResponse) templates = Jinja2Templates(directory=base_dir / "templates")
async def get_section_list(request: Request): templates.env.globals.update({"_": _})
@router.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="root_index.html",
context={ context={
"version": __version__, "version": __version__,
"sections": SECTIONS, "sections": SECTIONS,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,104 +0,0 @@
/*
base
*/
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
/*
table
*/
table {
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
/*
a.button
*/
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
/*
app
*/
.app-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
}
.app-title {
display: flex;
justify-content: center;
flex-grow: 1;
}
.app-link-home > * {
margin-left: 2rem;
width: 2rem;
height: 2rem;
background-image: url("/static/common/gallery.png");
background-size: contain;
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}
ul.app-list {
list-style: none;
}
ul.app-list > li {
border: 1px solid lightgrey;
}
ul.app-list > li > a {
display: flex;
gap: 0.25rem;
padding: 0.5rem 2rem;
text-decoration: none;
color: inherit;
}
ul.app-list > li:hover {
border-color: blue;
}
ul.app-list > li:hover > a {
color: blue;
}

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="{{request.state.language}}">
<head>
{% block head %}
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet"
href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.es.js?v={{version}}"></script>
<link rel="icon"
href="/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
</head>
<body>
<div class="app col-lg-8 mx-auto p-3 py-md-5">
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
<app-link href="/"
icon="gear">API Gallery</app-link>
{% block header %}{% endblock %}
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-language"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="Select language (default)">
<span class="me-2 language-icon-active">🇬🇧</span>
<span class="d-lg-none ms-2"
id="bd-language-text">Select language</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-language-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="en"
aria-pressed="false">
<span class="me-2 language-icon">🇬🇧</span>
English
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="ru"
aria-pressed="false">
<span class="me-2 language-icon">🇷🇺</span>
Russian
</button>
</li>
</ul>
</li>
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-theme"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="Toggle theme (auto)">
<i class="bi me-2 opacity-50 theme-icon-active"></i>
<span class="d-lg-none ms-2"
id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-theme-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light"
aria-pressed="false">
<i class="bi bi-sun-fill me-2 opacity-50 theme-icon"></i>
Light
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false">
<i class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></i>
Dark
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto"
aria-pressed="true">
<i class="bi bi-circle-half me-2 opacity-50 theme-icon"></i>
Auto
</button>
</li>
</ul>
</li>
</ul>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="pt-5 my-5 text-muted border-top">
Created by shmyga &middot; &copy; 2026
</footer>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
if (widget) {
document.body.classList.add('widget');
}
}());
</script>
</body>
</html>

View File

@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Информация</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="icon"
href="/static/common/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
<span>Информация</span>
</div>
</h3>
<ul class="app-list">
{% for section in sections %}
<li>
<a href="{{section.link}}">
<span class="icon"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
<span>{{section.title}}</span>
</a>
</li>
{% endfor %}
</ul>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Index{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block content %}
<h1>View</h1>
<div class="list-group mb-5">
{% for section in sections %}
<a href="{{section.link}}"
class="list-group-item list-group-item-action px-4">
<app-link href="{{section.link}}"
icon="{{section.icon}}">
{{_(section.title)}}
</app-link>
</a>
{% endfor %}
</div>
<hr class="col-3 col-md-2 mb-5">
<h1>Docs</h1>
<a href="/docs"
target="_blank">
<h4>Swagger</h4>
</a>
{% endblock %}

View File

@@ -0,0 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Gallery\n"
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Weather"
msgstr "Погода"
msgid "TV program"
msgstr "Телепрограмма"

View File

@@ -1,28 +1,34 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.sketch.schedule.api import ScheduleApi from gallery.easel.core import AppRequest
from gallery.sketch.schedule.catalog import BUNDLE from gallery.sketch.schedule.catalog import BUNDLE
from gallery.version import __version__ from gallery.version import __version__
from ..common.util import TagType, TagUtil from ..common.util import TagType, TagUtil
from ..translation import _
from .filters import timedelta_format from .filters import timedelta_format
base_dir = Path(__file__).parent
templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.globals.update({"_": _})
templates.env.filters["timedelta_format"] = timedelta_format
def mount(app: FastAPI): router = APIRouter()
base_dir = Path(__file__).parent
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: Request): @router.get("/schedule", response_class=HTMLResponse)
schedule_api: ScheduleApi = request.app.state.schedule_api async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels() channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels) channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -34,15 +40,12 @@ def mount(app: FastAPI):
}, },
) )
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: Request, tag: str, live: bool = False): @router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
schedule_api: ScheduleApi = request.app.state.schedule_api schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels() results = await schedule_api.get_all_schedules(tag_value.date)
responses = [
await schedule_api.get_channel_schedule(channel, tag_value.date)
for channel in channels
]
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="schedule.html", name="schedule.html",
@@ -50,21 +53,22 @@ def mount(app: FastAPI):
"version": __version__, "version": __version__,
"tag_util": TagUtil, "tag_util": TagUtil,
"datetime": datetime, "datetime": datetime,
"channels": channels, "response": results[0],
"response": responses[0], "responses": results,
"responses": responses,
"live": live, "live": live,
}, },
) )
@app.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str): @router.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today") return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: Request, channel: str, tag: str): @router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
schedule_api: ScheduleApi = request.app.state.schedule_api schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) response = await schedule_api.get_channel_schedule(channel, tag_value.date)
else: else:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,18 +0,0 @@
tr {
border-bottom: 1px solid lightgray;
}
td {
text-align: left;
}
tr.live {
font-weight: bold;
}
.title {
margin-top: 0.5rem;
font-style: italic;
font-weight: bold;
font-size: 120%;
}

View File

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

View File

@@ -1,38 +1,18 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}{{_("TV program")}}{% endblock %}
<head> {% block content %}
<meta charset="UTF-8"> <h1>{{_("TV program")}}</h1>
<meta name="viewport" <div class="list-group mb-5">
content="width=device-width, initial-scale=1.0"> <a href="schedule/tag/today"
<meta http-equiv="X-UA-Compatible" class="list-group-item list-group-item-action px-4">
content="ie=edge"> <span class="fw-bold">Все</span>
<title>ТВ</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a> </a>
<div class="app-title">
<span>Телепрограмма</span>
</div>
</h3>
<ul class="app-list">
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
{% for channel in channels %} {% for channel in channels %}
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li> <a href="schedule/{{channel.id}}"
class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span>
</a>
{% endfor %} {% endfor %}
</ul> </div>
</body> {% endblock %}
</html>

View File

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

View File

@@ -0,0 +1,34 @@
import gettext
from contextvars import ContextVar
from pathlib import Path
from fastapi import Cookie, Header, Request
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = (
ContextVar("translation")
)
async def set_language(
request: Request,
accept_language: str = Header("en"),
language: str | None = Cookie(None),
):
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
lang = language or accept_language.split(",")[0].split("-")[0]
try:
t = gettext.translation(
"messages", localedir=Path(__file__).parent / "locales", languages=[lang]
)
except FileNotFoundError:
t = gettext.NullTranslations()
token = _translation.set(t)
request.state.language = lang
yield lang
_translation.reset(token)
def _(message: str) -> str:
return _translation.get().gettext(message)

View File

@@ -1,29 +1,33 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import APIRouter, FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.sketch.weather.api import WeatherApi from gallery.easel.core import AppRequest
from gallery.sketch.weather.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
from gallery.version import __version__ from gallery.version import __version__
from ..common.util import TagType, TagUtil from ..common.util import TagType, TagUtil
from ..translation import _
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI): base_dir = Path(__file__).parent
base_dir = Path(__file__).parent templates = Jinja2Templates(
app.mount("/static/weather", StaticFiles(directory=base_dir / "static")) directory=[
templates = Jinja2Templates(directory=base_dir / "templates") base_dir.parent / "common/templates",
templates.env.filters["wind_direction_icon"] = wind_direction_icon base_dir / "templates",
templates.env.filters["cloudness_icon"] = cloudness_icon ]
)
templates.env.globals.update({"_": _})
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: Request, response: WeatherResponse):
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="weather.html", name="weather.html",
@@ -35,50 +39,45 @@ def mount(app: FastAPI):
}, },
) )
@app.get("/weather", response_class=HTMLResponse) router = APIRouter()
async def get_weather_list(request: Request):
weather_api: WeatherApi = request.app.state.weather_api @router.get("/weather", response_class=HTMLResponse)
locations = await weather_api.get_locations() async def get_weather_index(request: AppRequest, query: str | None = None):
locations_data = BUNDLE.select_items(locations) weather_api = request.app.state.api.weather
locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={ context={
"version": __version__, "version": __version__,
"locations": locations_data, "locations": locations,
}, },
) )
@app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): @router.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
async def get_weather_day_mock(request: Request):
response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse) @router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_days_mock(request: Request): async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
response = WEATHER_MOCK_DATA.get_response("days") weather_api = request.app.state.api.weather
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: Request, location: str, date: datetime.date):
weather_api: WeatherApi = request.app.state.weather_api
response = await weather_api.get_day(location, date) response = await weather_api.get_day(location, date)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: Request, location: str, days: int): @router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
weather_api: WeatherApi = request.app.state.weather_api async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: Request, location: str, tag: str): @router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
weather_api: WeatherApi = request.app.state.weather_api weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date) response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS: elif tag_value.type == TagType.DAYS:

View File

@@ -1,12 +1,19 @@
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
Sky,
WindDirection,
WindDirectionDeg,
)
def wind_direction_icon(wind_direction: WindDirection) -> str: def wind_direction_icon(wind_direction_deg: float) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction
return { return {
WindDirection.N: "⬇️", WindDirection.N: "⬇️",
WindDirection.NO: "↙️", WindDirection.NE: "↙️",
WindDirection.O: "⬅️", WindDirection.E: "⬅️",
WindDirection.SO: "↖️", WindDirection.SE: "↖️",
WindDirection.S: "⬆️", WindDirection.S: "⬆️",
WindDirection.SW: "↗️", WindDirection.SW: "↗️",
WindDirection.W: "➡️", WindDirection.W: "➡️",
@@ -31,6 +38,8 @@ def cloudness_icon(sky: Sky) -> list[str]:
Cloudness.CLOUDY: "", Cloudness.CLOUDY: "",
Cloudness.MAINLY_CLOUDY: "☁️", Cloudness.MAINLY_CLOUDY: "☁️",
}[sky.cloudness] }[sky.cloudness]
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
main_icon = "🌨️"
else: else:
main_icon = "🌧️" main_icon = "🌧️"
icons = [main_icon] icons = [main_icon]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,72 +0,0 @@
.header {
font-size: 1rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.1);
}
.date.now {
background: rgba(0, 128, 255, 0.2);
}
.date .value a {
all: unset;
cursor: pointer;
}
.cloudness {
vertical-align: top;
}
.cloudness .icon {
font-size: 1rem;
}
.cloudness .icon:first-child {
font-size: 2rem;
}
.temperature {
padding: 0;
}
.temperature .value {
padding: 0.1rem 0.4rem;
}
.temperature .value.positive {
color: orangered;
}
.temperature .value.negative {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure {
padding: 0;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,37 +1,73 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}Weather{% endblock %}
<head> {% block content %}
<meta charset="UTF-8"> <h1>Weather</h1>
<meta name="viewport" <form action=""
content="width=device-width, initial-scale=1.0"> method="get"
<meta http-equiv="X-UA-Compatible" class="mb-4">
content="ie=edge"> <div class="input-group mb-3">
<title>Погода</title> <input type="text"
<link rel="stylesheet" class="form-control"
href="/static/common/style.css?v={{version}}"> id="query"
<link rel="stylesheet" name="query"
href="/static/weather/style.css?v={{version}}"> placeholder="Enter the city name">
<link rel="icon" <button class="btn btn-primary"
href="/static/weather/favicon.ico?v={{version}}" type="submit">Search</button>
type="image/x-icon">
</head>
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
<span>Погода</span>
</div> </div>
</h3> </form>
<ul class="app-list"> <ul id="locations"
class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<li><a href="weather/{{location.id}}">{{location.name}}</a></li> <a href="weather/{{location.id}}"
class="list-group-item list-group-item-action px-4"
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
<span class="text-primary">{{location.name}}</span>
<span class="small ms-1 text-secondary">
{{location.country}}, {{location.district}}, {{location.subdistrict}}
</span>
<span></span>
</a>
{% endfor %} {% endfor %}
</ul> </ul>
</body> <script>
(function () {
document.loadLocations = () => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
const container = document.querySelector('#locations');
container.innerHTML = '';
for (const [id, name] of Object.entries(locations)) {
const element = document.createElement('a');
element.href = `weather/${id}`;
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
element.innerHTML = `
<span class="text-primary me-auto">${name}</span>
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">&#x2715;</span>
`;
container.appendChild(element);
}
}
</html> document.saveLocation = (location) => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
locations[location.id] = location.name;
window.localStorage.setItem('locations', JSON.stringify(locations));
}
document.removeLocation = (id) => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
delete locations[id];
window.localStorage.setItem('locations', JSON.stringify(locations));
document.loadLocations();
}
const params = new URLSearchParams(window.location.search);
const searchQuery = params.get('query');
if (searchQuery) {
document.querySelector('#query').value = searchQuery;
} else {
document.loadLocations();
}
})();
</script>
{% endblock %}

View File

@@ -1,29 +1,13 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en"> {% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
<head> {% block header %}
<meta charset="UTF-8"> <span class="fs-4 text-body ms-2 me-2">/</span>
<meta name="viewport" <app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
content="width=device-width, initial-scale=1.0"> {% endblock %}
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
<body class="app-container"> {% block content %}
<h3 class="app-header"> <h4>
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
{% if response.period == 'day' %} {% if response.period == 'day' %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
@@ -36,9 +20,10 @@
{% if response.period == 'days' %} {% if response.period == 'days' %}
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span> <span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
{% endif %} {% endif %}
</div> </h4>
</h3> <div class="table-responsive">
<table> <table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody> <tbody>
<!-- date --> <!-- date -->
<tr> <tr>
@@ -179,6 +164,5 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</body> </div>
{% endblock %}
</html>

View File

@@ -6,19 +6,28 @@ import uvicorn
from gallery.easel import build_app from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi from gallery.sketch.weather.cached import CachedWeatherApi
weather_api = CachedWeatherApi(GismeteoApi()) api = ApiBundle(
schedule_api = CachedScheduleApi(MatchTvApi()) [
app = build_app(weather_api, schedule_api) CachedScheduleApi(YandexTvApi()),
CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
]
)
app = build_app(api)
def run(): def run():
uvicorn.run( uvicorn.run(
"gallery.main:app", "gallery.main:app",
host="0.0.0.0", host=environ.get("GALLERY_HOST", "0.0.0.0"),
port=8000, port=int(environ.get("GALLERY_PORT", 8000)),
log_config=str(Path(__file__).parent / "logging.yaml"), log_config=str(Path(__file__).parent / "logging.yaml"),
reload="DEBUG" in environ, reload="DEBUG" in environ,
) )

View File

@@ -1,13 +1,13 @@
import datetime import datetime
import json
import logging import logging
from typing import Any, Dict, List from typing import Any
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from . import datehelp from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -34,7 +34,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse: def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup) widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +52,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_manydays(self, data: str) -> WeatherResponse: def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup) widget = DAYS_PARSER.parse_widget(soup)
@@ -69,11 +69,33 @@ class GismeteoApi(WeatherApi):
values=values, values=values,
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return [ geo = "ru"
LocationId.OREL, latitude = 52.968498
LocationId.ZMIYEVKA, longitude = 36.0695
] data = json.loads(
await self.SOURCE.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
)
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
district=item["translations"]["kk"]["district"]["name"],
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: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}") data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -4,8 +4,7 @@ import logging
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import ChannelId from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv") logger = logging.getLogger("matchtv")
@@ -15,7 +14,7 @@ class MatchTvApi(ScheduleApi):
PROVIDER = "matchtv" PROVIDER = "matchtv"
SOURCE = ApiSource("https://matchtv.ru") SOURCE = ApiSource("https://matchtv.ru")
async def get_channels(self) -> list[str]: async def get_channels(self) -> list[ChannelId]:
return [ return [
ChannelId.MATCH_TV, ChannelId.MATCH_TV,
ChannelId.MATCH_IGRA, ChannelId.MATCH_IGRA,
@@ -27,21 +26,31 @@ class MatchTvApi(ScheduleApi):
] ]
async def get_channel_schedule( async def get_channel_schedule(
self, channel_id: str, date: datetime.date self, channel_id: ChannelId, date: datetime.date
) -> Schedule: ) -> Schedule:
endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}" endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
data = await self.SOURCE.request(endpoint) data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
values = [] values = []
channel_name = soup.select_one(".caption__heading").text.split("|")[0].strip() channel_name = (
soup.select_one(".p-tv-guide-header__title")
.text.replace("Телепрограмма ", "")
.strip()
)
current_day = datetime.datetime.combine( current_day = datetime.datetime.combine(
date.today(), datetime.datetime.min.time() date.today(), datetime.datetime.min.time()
) )
end = current_day + datetime.timedelta(days=1, hours=6) end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None prev_value: ScheduleValue | None = None
for item in soup.select(".teleprogram-schedule .teleprogram-schedule__item"): for item in soup.select(
title = item.select_one(".teleprogram-item__title").text.strip() ".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
time_str = item.select_one(".teleprogram-item__time").text.strip() ):
title = item.select_one(
".p-tv-guide-schedule-channel-transmission__title"
).text.strip()
time_str = item.select_one(
".p-tv-guide-schedule-channel-transmission__time-block"
).text.strip()
hours, minutes = map(int, time_str.split(":")) hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes) item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour: if prev_value is not None and item_date.hour < prev_value.start.hour:

View File

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

File diff suppressed because one or more lines are too long

View File

View File

@@ -0,0 +1,67 @@
import datetime
import logging
from collections import defaultdict
from aiocache import cached
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from .openweather import Forecast, OpenWeather
from .parser import FORECAST_ITEM_PARSER
logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
@classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]:
return tuple(map(float, location_id.split(":", maxsplit=2)))
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.DAY,
)
async def _get_location_forecast(self, location_id: str) -> Forecast:
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
values = []
for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date:
values.append(value)
return WeatherResponse(
location=location_id,
date=date,
period="day",
values=values,
)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item)
item_date = value.date.replace(hour=0, minute=0)
values_by_date[item_date].append(value)
values = [
merge_weather_values(date, values)
for date, values in values_by_date.items()
]
return WeatherResponse(
location=location_id,
date=datetime.date.today(),
period="days",
values=list(sorted(values, key=lambda item: item.date)),
)

View File

@@ -2,4 +2,4 @@ from pathlib import Path
from gallery.sketch.mock import MockData from gallery.sketch.mock import MockData
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data") OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -0,0 +1,83 @@
import json
from pydantic import BaseModel, Field
from gallery.sketch.source import ApiSource
class Model(BaseModel):
class Config:
use_enum_values = True
class Main(Model):
temp: float
feels_like: float
temp_min: float
temp_max: float
pressure: int
sea_level: int
grnd_level: int
humidity: int
temp_kf: float
class Weather(Model):
id: int
main: str
description: str
icon: str
class Clouds(Model):
all: int
class Wind(Model):
speed: float
deg: int
gust: float
class Rain(Model):
interval_3h: float = Field(..., alias="3h")
class Sys(Model):
pod: str
class ForecastItem(Model):
dt: int
main: Main
weather: list[Weather]
clouds: Clouds
wind: Wind
visibility: int
pop: float
rain: Rain | None = None
sys: Sys
dt_txt: str
class Forecast(Model):
cod: str
message: int
cnt: int
list: list[ForecastItem]
class OpenWeather:
BASE_URL = "https://api.openweathermap.org"
def __init__(self, api_key: str):
self._api_key = api_key
self._source = ApiSource(self.BASE_URL)
async def get_forecast(self, lat: float, lon: float) -> Forecast:
endpoint = (
f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
)
response = await self._source.request(endpoint)
response_data = json.loads(response)
return Forecast(**response_data)

View File

@@ -0,0 +1,52 @@
import datetime
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
from gallery.sketch.weather.util import build_weather_value
from .openweather import ForecastItem
class ForecastItemParser:
CLOUDNESS_MAP: dict[str, Cloudness] = {
"clear sky": Cloudness.CLEAR,
"few clouds": Cloudness.PARTLY_CLOUDY,
"scattered clouds": Cloudness.PARTLY_CLOUDY,
"broken clouds": Cloudness.CLOUDY,
"overcast clouds": Cloudness.MAINLY_CLOUDY,
"light rain": Cloudness.CLOUDY,
}
PRECIPITATION_MAP: dict[str, Precipitation] = {
"light rain": Precipitation.SMALL_RAIN,
"rain": Precipitation.RAIN,
"heavy rain": Precipitation.SHOWER,
}
def parse(self, item: ForecastItem) -> WeatherValue:
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
item_date = (
item_date.replace(tzinfo=datetime.timezone.utc)
.astimezone(tz=None)
.replace(tzinfo=None)
)
value = build_weather_value(item_date)
# TODO parse temperature interval flag
value.temperature = [round(item.main.temp)]
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
value.pressure = [round(item.main.pressure / 133.3 * 100)]
value.humidity = item.main.humidity
value.wind_speed = round(item.wind.speed)
value.wind_gust = round(item.wind.gust)
value.wind_direction = item.wind.deg
value.sky.cloudness = self.CLOUDNESS_MAP.get(
item.weather[0].description, Cloudness.CLEAR
)
value.sky.precipitation = self.PRECIPITATION_MAP.get(
item.weather[0].description, Precipitation.NO
)
if item.rain:
value.precipitation = round(item.rain.interval_3h, 1)
return value
FORECAST_ITEM_PARSER = ForecastItemParser()

View File

View File

@@ -0,0 +1,94 @@
import datetime
import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv")
CHANNELS_MAP: dict[ChannelId, str] = {
ChannelId.MATCH_TV: "match-tv-49",
ChannelId.MATCH_IGRA: "match-igra-1174",
ChannelId.MATCH_ARENA: "match-arena-1173",
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
ChannelId.MATCH_STRANA: "match-strana-1356",
ChannelId.MATCH_PLANETA: "match-planeta-1177",
# ChannelId.EUROSPORT: "eurosport-677",
# ChannelId.EUROSPORT_2: "eurosport-2-720",
ChannelId.START: "start-103",
}
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"
),
}
class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
async def get_channels(self) -> list[ChannelId]:
return list(CHANNELS_MAP.keys())
async def get_channel_schedule(
self, channel_id: ChannelId, date: datetime.date
) -> Schedule:
endpoint = f"channel/{CHANNELS_MAP[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), date=date, values=values
)

View File

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

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

@@ -0,0 +1,28 @@
from .api import API, Api
from .schedule.api import ScheduleApi
from .weather.api import WeatherApi
class ApiBundle(list[Api]):
def __init__(self, values: list[Api]) -> None:
super().__init__(values)
def get_api_by_provider(self, provider: str) -> Api:
for value in self:
if value.PROVIDER == provider:
return value
raise ValueError(provider)
def get_api_by_type(self, api_type: type[API]) -> API:
for value in self:
if isinstance(value, api_type):
return value
raise ValueError(api_type)
@property
def weather(self) -> WeatherApi:
return self.get_api_by_type(WeatherApi)
@property
def schedule(self) -> ScheduleApi:
return self.get_api_by_type(ScheduleApi)

View File

@@ -1,15 +1,19 @@
from typing import Generic, TypeVar from typing import Generic, NamedTuple
from gallery.util import TimeUnit from gallery.util import TimeUnit
from .api import Api from .api import API, Api
API = TypeVar("API", bound=Api)
class CachePreset(NamedTuple):
ttl: int = TimeUnit.HOUR
alias: str = "redis"
DEFAULT_CACHE_PRESET = CachePreset()
class CachedApi(Api, Generic[API]): class CachedApi(Api, Generic[API]):
CACHE_TTL: int = TimeUnit.HOUR
CACHE_ALIAS: str = "redis"
CACHE_KEY: str CACHE_KEY: str
def __init__(self, api: API): def __init__(self, api: API):

View File

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

View File

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

View File

@@ -1,14 +1,28 @@
import asyncio
import datetime import datetime
from ..api import Api from ..api import Api
from .model import Schedule from .model import ChannelId, Schedule
class ScheduleApi(Api): class ScheduleApi(Api):
async def get_channels(self) -> list[str]: INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]:
raise NotImplementedError raise NotImplementedError
async def get_channel_schedule( async def get_channel_schedule(
self, channel_id: str, date: datetime.date self, channel_id: ChannelId, date: datetime.date
) -> Schedule: ) -> Schedule:
raise NotImplementedError raise NotImplementedError
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
channels = await self.get_channels()
results = []
for channel in channels:
results.append(
await self.get_channel_schedule(channel_id=channel, date=date)
)
if self.INTERVAL > 0:
await asyncio.sleep(self.INTERVAL)
return results

View File

@@ -2,10 +2,13 @@ import datetime
from aiocache import cached from aiocache import cached
from gallery.sketch.cached import CachedApi from gallery.sketch.cached import CachedApi, CachePreset
from gallery.util import TimeUnit
from .api import ScheduleApi from .api import ScheduleApi
from .model import Schedule from .model import ChannelId, Schedule
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]): class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
@@ -13,20 +16,27 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels", key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_channels(self) -> list[str]: async def get_channels(self) -> list[ChannelId]:
return await self._api.get_channels() return await self._api.get_channels()
@cached( @cached(
key_builder=lambda fun, self, channel_id, date: ( key_builder=lambda fun, self, channel_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_channel_schedule( async def get_channel_schedule(
self, channel_id: str, date: datetime.date self, channel_id: ChannelId, date: datetime.date
) -> Schedule: ) -> Schedule:
return await self._api.get_channel_schedule(channel_id, date) return await self._api.get_channel_schedule(channel_id, date)
@cached(
key_builder=lambda fun, self, date: (
f"api.{self.CACHE_KEY}.{self.provider}.all.{date}"
),
**CACHE_PRESET._asdict(),
)
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
return await self._api.get_all_schedules(date)

View File

@@ -1,22 +1,6 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle from gallery.sketch.catalog import CatalogBundle
from .model import Channel from .model import Channel, ChannelId
class ChannelId(str, Enum):
MATCH_TV = "matchtv"
MATCH_IGRA = "igra"
MATCH_ARENA = "arena"
MATCH_FUTBOL_1 = "futbol-1"
MATCH_FUTBOL_2 = "futbol-2"
MATCH_FUTBOL_3 = "futbol-3"
MATCH_STRANA = "strana"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle( BUNDLE = CatalogBundle(
[ [
@@ -27,5 +11,11 @@ BUNDLE = CatalogBundle(
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"), Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"), Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"), Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
Channel(id=ChannelId.EUROSPORT, name="Europsort"),
Channel(id=ChannelId.EUROSPORT_2, name="Europsort 2"),
Channel(id=ChannelId.START, name="Старт!"),
Channel(id=ChannelId.TEST, name="Тест"),
] ]
) )

View File

@@ -1,4 +1,5 @@
import datetime import datetime
from enum import StrEnum
from pydantic import BaseModel from pydantic import BaseModel
@@ -8,8 +9,26 @@ class Model(BaseModel):
use_enum_values = True use_enum_values = True
class ChannelId(StrEnum):
MATCH_TV = "matchtv"
MATCH_IGRA = "igra"
MATCH_ARENA = "arena"
MATCH_FUTBOL_1 = "futbol-1"
MATCH_FUTBOL_2 = "futbol-2"
MATCH_FUTBOL_3 = "futbol-3"
MATCH_STRANA = "strana"
MATCH_PLANETA = "planeta"
EUROSPORT = "eurosport"
EUROSPORT_2 = "eurosport-2"
START = "start"
TEST = "test"
def __str__(self) -> str:
return self.value
class Channel(Model): class Channel(Model):
id: str id: ChannelId
name: str name: str

View File

@@ -19,18 +19,18 @@ class ApiSource:
user_agent: str = DEFAULT_USER_AGENT, user_agent: str = DEFAULT_USER_AGENT,
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
): ):
self._base_url = base_url self._base_url = base_url
self._user_agent = user_agent self._user_agent = user_agent
self._timeout = timeout self._timeout = timeout
self._cookies = cookies self._cookies = cookies
self._headers = headers
async def request(self, endpoint: str) -> str: async def request(self, endpoint: str) -> str:
url = f"{self._base_url}/{endpoint}" url = f"{self._base_url}/{endpoint}"
logger.info(url) logger.info(url)
headers = { headers = {"User-Agent": self._user_agent, **(self._headers or {})}
"User-Agent": self._user_agent,
}
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
headers=headers, headers=headers,
cookies=self._cookies, cookies=self._cookies,

View File

@@ -1,12 +1,12 @@
import datetime import datetime
from ..api import Api from ..api import Api
from .model import WeatherResponse from .model import Location, WeatherResponse
class WeatherApi(Api): class WeatherApi(Api):
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:

View File

@@ -2,29 +2,29 @@ import datetime
from aiocache import cached from aiocache import cached
from gallery.sketch.cached import CachedApi from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
from .api import WeatherApi from .api import WeatherApi
from .model import WeatherResponse from .model import Location, WeatherResponse
CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]): class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather" CACHE_KEY = "weather"
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return await self._api.get_locations() return await self._api.find_locations(query)
@cached( @cached(
key_builder=lambda fun, self, location_id, date: ( key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
return await self._api.get_day(location_id, date) return await self._api.get_day(location_id, date)
@@ -33,8 +33,7 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
key_builder=lambda fun, self, location_id, date: ( key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_days(self, location_id: str, days: int) -> WeatherResponse: async def get_days(self, location_id: str, days: int) -> WeatherResponse:
return await self._api.get_days(location_id, days) return await self._api.get_days(location_id, days)

View File

@@ -1,21 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Location
class LocationId(str, Enum):
OREL = "orel-4432"
ZMIYEVKA = "zmiyevka-184640"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Location(id=LocationId.OREL, name="Орёл"),
Location(id=LocationId.ZMIYEVKA, name="Змиёвка"),
]
)

View File

@@ -1,12 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
from gallery.sketch.weather.model import WeatherResponse
class WeatherMockData(MockData):
def get_response(self, key: str) -> WeatherResponse:
return WeatherResponse(**self.get_json(key))
WEATHER_MOCK_DATA = WeatherMockData(Path(__file__).parent / "data")

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"day","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[20],"wind_speed":1,"wind_gust":1,"wind_direction":"SW","precipitation":0.0,"pressure":[744],"humidity":85},{"date":"2024-07-29T03:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[18],"wind_speed":1,"wind_gust":1,"wind_direction":"W","precipitation":0.6,"pressure":[742],"humidity":96},{"date":"2024-07-29T06:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":1,"wind_gust":2,"wind_direction":"S","precipitation":4.9,"pressure":[741],"humidity":95},{"date":"2024-07-29T09:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":7,"wind_direction":"S","precipitation":3.8,"pressure":[740],"humidity":83},{"date":"2024-07-29T12:00:00","sky":{"cloudness":"clear","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":0.0,"pressure":[740],"humidity":54},{"date":"2024-07-29T15:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[738],"humidity":48},{"date":"2024-07-29T18:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":63},{"date":"2024-07-29T21:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[17],"wind_speed":3,"wind_gust":7,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":77}]}

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"days","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[21,17],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":9.3,"pressure":[744,737],"humidity":96},{"date":"2024-07-30T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":true,"fog":false},"temperature":[19,14],"wind_speed":2,"wind_gust":7,"wind_direction":"N","precipitation":11.0,"pressure":[737,733],"humidity":100},{"date":"2024-07-31T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[22,14],"wind_speed":3,"wind_gust":10,"wind_direction":"NW","precipitation":1.8,"pressure":[741,738],"humidity":99},{"date":"2024-07-01T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":10,"wind_direction":"W","precipitation":0.1,"pressure":[741,740],"humidity":97},{"date":"2024-07-02T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,17],"wind_speed":2,"wind_gust":8,"wind_direction":"W","precipitation":0.2,"pressure":[740],"humidity":84},{"date":"2024-07-03T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":1,"wind_gust":4,"wind_direction":"N","precipitation":0.0,"pressure":[740,739],"humidity":99},{"date":"2024-07-04T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":3,"wind_gust":6,"wind_direction":"N","precipitation":0.0,"pressure":[743,740],"humidity":92},{"date":"2024-07-05T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":true,"fog":false},"temperature":[25,15],"wind_speed":3,"wind_gust":7,"wind_direction":"NW","precipitation":2.1,"pressure":[744,743],"humidity":98},{"date":"2024-07-06T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":5,"wind_direction":"NW","precipitation":0.3,"pressure":[745,744],"humidity":98},{"date":"2024-07-07T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[26,14],"wind_speed":2,"wind_gust":5,"wind_direction":"NW","precipitation":0.2,"pressure":[747,745],"humidity":95}]}

View File

@@ -12,6 +12,11 @@ class Model(BaseModel):
class Location(Model): class Location(Model):
id: str id: str
name: str name: str
lat: float
lon: float
country: str
district: str
subdistrict: str
class Cloudness(str, Enum): class Cloudness(str, Enum):
@@ -25,7 +30,10 @@ class Precipitation(str, Enum):
NO = "no" NO = "no"
SMALL_RAIN = "small_rain" SMALL_RAIN = "small_rain"
RAIN = "rain" RAIN = "rain"
HEAVY_RAIN = "heavy_rain"
SHOWER = "shower" SHOWER = "shower"
SNOW = "snow"
HEAVY_SNOW = "heavy_snow"
class Sky(Model): class Sky(Model):
@@ -38,22 +46,69 @@ class Sky(Model):
class WindDirection(str, Enum): class WindDirection(str, Enum):
CALM = "calm" CALM = "calm"
N = "N" N = "N"
NO = "NO" NE = "NE"
O = "O" E = "E"
SO = "SO" SE = "SE"
S = "S" S = "S"
SW = "SW" SW = "SW"
W = "W" W = "W"
NW = "NW" NW = "NW"
class WindDirectionDeg(float):
@property
def direction(self) -> WindDirection:
return self.to_direction()
@property
def value(self) -> float:
return self
# pylint:disable=too-many-return-statements
def to_direction(self) -> WindDirection:
if self > 337.5 or self <= 22.25:
return WindDirection.N
elif self <= 67.5:
return WindDirection.NE
elif self <= 112.5:
return WindDirection.E
elif self <= 157.5:
return WindDirection.SE
elif self <= 202.5:
return WindDirection.S
elif self <= 247.5:
return WindDirection.SW
elif self <= 292.5:
return WindDirection.W
elif self <= 337.5:
return WindDirection.NW
else:
return WindDirection.CALM
@classmethod
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
return cls(
{
WindDirection.CALM: -1,
WindDirection.N: 0,
WindDirection.NE: 45,
WindDirection.E: 90,
WindDirection.SE: 135,
WindDirection.S: 180,
WindDirection.SW: 225,
WindDirection.W: 270,
WindDirection.NW: 315,
}[direction]
)
class WeatherValue(Model): class WeatherValue(Model):
date: datetime.datetime date: datetime.datetime
sky: Sky sky: Sky
temperature: list[int] temperature: list[int]
wind_speed: int wind_speed: int
wind_gust: int wind_gust: int
wind_direction: WindDirection wind_direction: float
precipitation: float precipitation: float
pressure: list[int] pressure: list[int]
humidity: int humidity: int

View File

@@ -1,6 +1,7 @@
import datetime import datetime
import statistics
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
def build_weather_value(date: datetime.datetime) -> WeatherValue: def build_weather_value(date: datetime.datetime) -> WeatherValue:
@@ -15,8 +16,49 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
temperature=[], temperature=[],
wind_speed=0, wind_speed=0,
wind_gust=0, wind_gust=0,
wind_direction=WindDirection.CALM, wind_direction=WindDirectionDeg(-1),
precipitation=0, precipitation=0,
pressure=[], pressure=[],
humidity=0, humidity=0,
) )
def merge_weather_values(
date: datetime.datetime, values: list[WeatherValue]
) -> WeatherValue:
result = build_weather_value(date)
temperatures = []
pressures = []
humidities = []
wind_speeds = []
wind_gusts = []
wind_directions = []
cloudnesses = []
precipitations = []
precipitation = 0
for value in values:
temperatures += value.temperature
pressures += value.pressure
humidities.append(value.humidity)
wind_speeds.append(value.wind_speed)
wind_gusts.append(value.wind_gust)
wind_directions.append(value.wind_direction)
cloudnesses.append(value.sky.cloudness)
precipitations.append(value.sky.precipitation)
precipitation += value.precipitation
result.temperature = [max(temperatures), min(temperatures)]
result.pressure = [max(pressures), min(pressures)]
result.humidity = round(statistics.mean(humidities))
result.wind_speed = round(statistics.mean(wind_speeds))
result.wind_gust = round(statistics.mean(wind_gusts))
result.wind_direction = statistics.mean(wind_directions)
# TODO: merge cloudnesses
for item in cloudnesses:
if item != Cloudness.CLEAR:
result.sky.cloudness = item
# TODO: merge precipitations
for item in precipitations:
if item != Precipitation.NO:
result.sky.precipitation = item
result.precipitation = precipitation
return result

View File

@@ -1,5 +1,11 @@
from pathlib import Path
class TimeUnit: class TimeUnit:
SECOND = 1 SECOND = 1
MINUTE = 60 * SECOND MINUTE = 60 * SECOND
HOUR = 60 * MINUTE HOUR = 60 * MINUTE
DAY = 24 * HOUR DAY = 24 * HOUR
root_path = Path(__file__).parent.parent

View File

@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.2.1"

91
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]] [[package]]
name = "aiocache" name = "aiocache"
@@ -6,6 +6,7 @@ version = "0.12.2"
description = "multi backend asyncio cache" description = "multi backend asyncio cache"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"},
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"},
@@ -25,6 +26,7 @@ version = "3.9.5"
description = "Async http client/server framework (asyncio)" description = "Async http client/server framework (asyncio)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"},
{file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"},
@@ -112,7 +114,7 @@ multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0" yarl = ">=1.0,<2.0"
[package.extras] [package.extras]
speedups = ["Brotli", "aiodns", "brotlicffi"] speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
[[package]] [[package]]
name = "aiosignal" name = "aiosignal"
@@ -120,6 +122,7 @@ version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks" description = "aiosignal: a list of registered asynchronous callbacks"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
@@ -134,6 +137,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated" description = "Reusable constraint types to use with typing.Annotated"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "app"]
files = [ files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -145,6 +149,7 @@ version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations" description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
@@ -156,7 +161,7 @@ sniffio = ">=1.1"
[package.extras] [package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""]
trio = ["trio (>=0.23)"] trio = ["trio (>=0.23)"]
[[package]] [[package]]
@@ -165,6 +170,7 @@ version = "3.2.4"
description = "An abstract syntax tree for Python with inference support." description = "An abstract syntax tree for Python with inference support."
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["dev"]
files = [ files = [
{file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"}, {file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"},
{file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"}, {file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"},
@@ -176,6 +182,7 @@ version = "23.2.0"
description = "Classes Without Boilerplate" description = "Classes Without Boilerplate"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
{file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
@@ -186,8 +193,8 @@ cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
dev = ["attrs[tests]", "pre-commit"] dev = ["attrs[tests]", "pre-commit"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
tests = ["attrs[tests-no-zope]", "zope-interface"] tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-mypy = ["mypy (>=1.6) ; platform_python_implementation == \"CPython\" and python_version >= \"3.8\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.8\""]
tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] tests-no-zope = ["attrs[tests-mypy]", "cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
@@ -195,6 +202,7 @@ version = "4.12.3"
description = "Screen-scraping library" description = "Screen-scraping library"
optional = false optional = false
python-versions = ">=3.6.0" python-versions = ">=3.6.0"
groups = ["main"]
files = [ files = [
{file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
@@ -216,6 +224,7 @@ version = "24.4.2"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"},
{file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"},
@@ -250,7 +259,7 @@ platformdirs = ">=2"
[package.extras] [package.extras]
colorama = ["colorama (>=0.4.3)"] colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"] uvloop = ["uvloop (>=0.15.2)"]
@@ -260,6 +269,7 @@ version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["app"]
files = [ files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
@@ -271,6 +281,7 @@ version = "8.1.7"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app", "dev"]
files = [ files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
@@ -285,10 +296,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["app", "dev", "test"]
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
markers = {app = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
[[package]] [[package]]
name = "dateparser" name = "dateparser"
@@ -296,6 +309,7 @@ version = "1.2.0"
description = "Date parsing library designed to parse dates from HTML pages" description = "Date parsing library designed to parse dates from HTML pages"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830"}, {file = "dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830"},
{file = "dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30"}, {file = "dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30"},
@@ -318,6 +332,7 @@ version = "0.3.8"
description = "serialize all of Python" description = "serialize all of Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"},
{file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"},
@@ -333,6 +348,7 @@ version = "2.6.1"
description = "DNS toolkit" description = "DNS toolkit"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"},
{file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"},
@@ -353,6 +369,7 @@ version = "2.2.0"
description = "A robust email address syntax and deliverability validation library." description = "A robust email address syntax and deliverability validation library."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
{file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"},
@@ -368,6 +385,7 @@ version = "0.111.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "fastapi-0.111.1-py3-none-any.whl", hash = "sha256:4f51cfa25d72f9fbc3280832e84b32494cf186f50158d364a8765aabf22587bf"}, {file = "fastapi-0.111.1-py3-none-any.whl", hash = "sha256:4f51cfa25d72f9fbc3280832e84b32494cf186f50158d364a8765aabf22587bf"},
{file = "fastapi-0.111.1.tar.gz", hash = "sha256:ddd1ac34cb1f76c2e2d7f8545a4bcb5463bce4834e81abf0b189e0c359ab2413"}, {file = "fastapi-0.111.1.tar.gz", hash = "sha256:ddd1ac34cb1f76c2e2d7f8545a4bcb5463bce4834e81abf0b189e0c359ab2413"},
@@ -393,6 +411,7 @@ version = "0.0.4"
description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀" description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "fastapi_cli-0.0.4-py3-none-any.whl", hash = "sha256:a2552f3a7ae64058cdbb530be6fa6dbfc975dc165e4fa66d224c3d396e25e809"}, {file = "fastapi_cli-0.0.4-py3-none-any.whl", hash = "sha256:a2552f3a7ae64058cdbb530be6fa6dbfc975dc165e4fa66d224c3d396e25e809"},
{file = "fastapi_cli-0.0.4.tar.gz", hash = "sha256:e2e9ffaffc1f7767f488d6da34b6f5a377751c996f397902eb6abb99a67bde32"}, {file = "fastapi_cli-0.0.4.tar.gz", hash = "sha256:e2e9ffaffc1f7767f488d6da34b6f5a377751c996f397902eb6abb99a67bde32"},
@@ -410,6 +429,7 @@ version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence" description = "A list-like structure which implements collections.abc.MutableSequence"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"},
@@ -496,6 +516,7 @@ version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
@@ -507,6 +528,7 @@ version = "1.0.5"
description = "A minimal low-level HTTP client." description = "A minimal low-level HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
@@ -528,6 +550,7 @@ version = "0.6.1"
description = "A collection of framework independent HTTP protocol utils." description = "A collection of framework independent HTTP protocol utils."
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["app"]
files = [ files = [
{file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"},
{file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"},
@@ -576,6 +599,7 @@ version = "0.27.0"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
@@ -589,7 +613,7 @@ idna = "*"
sniffio = "*" sniffio = "*"
[package.extras] [package.extras]
brotli = ["brotli", "brotlicffi"] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
@@ -600,6 +624,7 @@ version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
groups = ["main", "app"]
files = [ files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
@@ -611,6 +636,7 @@ version = "2.0.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["test"]
files = [ files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
@@ -622,6 +648,7 @@ version = "5.13.2"
description = "A Python utility / library to sort Python imports." description = "A Python utility / library to sort Python imports."
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["dev"]
files = [ files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
@@ -636,6 +663,7 @@ version = "3.1.4"
description = "A very fast and expressive template engine." description = "A very fast and expressive template engine."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
@@ -653,6 +681,7 @@ version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!" description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
@@ -677,6 +706,7 @@ version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
@@ -746,6 +776,7 @@ version = "0.7.0"
description = "McCabe checker, plugin for flake8" description = "McCabe checker, plugin for flake8"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["dev"]
files = [ files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
@@ -757,6 +788,7 @@ version = "0.1.2"
description = "Markdown URL utilities" description = "Markdown URL utilities"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -768,6 +800,7 @@ version = "6.0.5"
description = "multidict implementation" description = "multidict implementation"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"},
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"},
@@ -867,6 +900,7 @@ version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker." description = "Type system extensions for programs checked with the mypy type checker."
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
groups = ["dev"]
files = [ files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
@@ -878,6 +912,7 @@ version = "24.1"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev", "test"]
files = [ files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
@@ -889,6 +924,7 @@ version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
@@ -900,6 +936,7 @@ version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
@@ -916,6 +953,7 @@ version = "1.5.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["test"]
files = [ files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
@@ -931,6 +969,7 @@ version = "2.8.2"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "app"]
files = [ files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
@@ -953,6 +992,7 @@ version = "2.20.1"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "app"]
files = [ files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"},
@@ -1054,6 +1094,7 @@ version = "2.18.0"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
@@ -1068,6 +1109,7 @@ version = "3.2.6"
description = "python code static checker" description = "python code static checker"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["dev"]
files = [ files = [
{file = "pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f"}, {file = "pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f"},
{file = "pylint-3.2.6.tar.gz", hash = "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3"}, {file = "pylint-3.2.6.tar.gz", hash = "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3"},
@@ -1092,6 +1134,7 @@ version = "8.3.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["test"]
files = [ files = [
{file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"},
{file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"},
@@ -1112,6 +1155,7 @@ version = "0.23.8"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["test"]
files = [ files = [
{file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"},
{file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"},
@@ -1130,6 +1174,7 @@ version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module" description = "Extensions to the standard Python datetime module"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [ files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@@ -1144,6 +1189,7 @@ version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables" description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
@@ -1158,6 +1204,7 @@ version = "0.0.9"
description = "A streaming multipart parser for Python" description = "A streaming multipart parser for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
{file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
@@ -1172,6 +1219,7 @@ version = "2024.1"
description = "World timezone definitions, modern and historical" description = "World timezone definitions, modern and historical"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"]
files = [ files = [
{file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
{file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
@@ -1183,6 +1231,7 @@ version = "6.0.1"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["app"]
files = [ files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
@@ -1243,6 +1292,7 @@ version = "5.0.8"
description = "Python client for Redis database and key-value store" description = "Python client for Redis database and key-value store"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"}, {file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"},
{file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"}, {file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"},
@@ -1258,6 +1308,7 @@ version = "2024.5.15"
description = "Alternative regular expression module, to replace re." description = "Alternative regular expression module, to replace re."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"}, {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"},
{file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"}, {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"},
@@ -1346,6 +1397,7 @@ version = "13.7.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false optional = false
python-versions = ">=3.7.0" python-versions = ">=3.7.0"
groups = ["app"]
files = [ files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
@@ -1364,6 +1416,7 @@ version = "1.5.4"
description = "Tool to Detect Surrounding Shell" description = "Tool to Detect Surrounding Shell"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
@@ -1375,6 +1428,7 @@ version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
groups = ["main"]
files = [ files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
@@ -1386,6 +1440,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under" description = "Sniff out which async library your code is running under"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1397,6 +1452,7 @@ version = "2.5"
description = "A modern CSS selector implementation for Beautiful Soup." description = "A modern CSS selector implementation for Beautiful Soup."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
{file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
@@ -1408,6 +1464,7 @@ version = "0.37.2"
description = "The little ASGI library that shines." description = "The little ASGI library that shines."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
@@ -1425,6 +1482,7 @@ version = "0.13.0"
description = "Style preserving TOML library" description = "Style preserving TOML library"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264"}, {file = "tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264"},
{file = "tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72"}, {file = "tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72"},
@@ -1436,6 +1494,7 @@ version = "0.12.3"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints." description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["app"]
files = [ files = [
{file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"},
{file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"},
@@ -1453,6 +1512,7 @@ version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "app"]
files = [ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
@@ -1464,6 +1524,8 @@ version = "2024.1"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [ files = [
{file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
{file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
@@ -1475,6 +1537,7 @@ version = "5.2"
description = "tzinfo object for the local timezone" description = "tzinfo object for the local timezone"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"},
{file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"},
@@ -1492,6 +1555,7 @@ version = "0.30.3"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"}, {file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
{file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"}, {file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"},
@@ -1504,12 +1568,12 @@ h11 = ">=0.8"
httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[package.extras] [package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]] [[package]]
name = "uvloop" name = "uvloop"
@@ -1517,6 +1581,8 @@ version = "0.19.0"
description = "Fast implementation of asyncio event loop on top of libuv" description = "Fast implementation of asyncio event loop on top of libuv"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["app"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
files = [ files = [
{file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"},
{file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"},
@@ -1553,7 +1619,7 @@ files = [
[package.extras] [package.extras]
docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"]
test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0) ; python_version >= \"3.12\"", "aiohttp (>=3.8.1) ; python_version < \"3.12\"", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"]
[[package]] [[package]]
name = "watchfiles" name = "watchfiles"
@@ -1561,6 +1627,7 @@ version = "0.22.0"
description = "Simple, modern and high performance file watching and code reload in python." description = "Simple, modern and high performance file watching and code reload in python."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "watchfiles-0.22.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da1e0a8caebf17976e2ffd00fa15f258e14749db5e014660f53114b676e68538"}, {file = "watchfiles-0.22.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:da1e0a8caebf17976e2ffd00fa15f258e14749db5e014660f53114b676e68538"},
{file = "watchfiles-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61af9efa0733dc4ca462347becb82e8ef4945aba5135b1638bfc20fad64d4f0e"}, {file = "watchfiles-0.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61af9efa0733dc4ca462347becb82e8ef4945aba5135b1638bfc20fad64d4f0e"},
@@ -1648,6 +1715,7 @@ version = "12.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["app"]
files = [ files = [
{file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"},
{file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"},
@@ -1729,6 +1797,7 @@ version = "1.9.4"
description = "Yet another URL library" description = "Yet another URL library"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
@@ -1827,6 +1896,6 @@ idna = ">=2.0"
multidict = ">=4.0" multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.1"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "7295e9ec7f7492017c5bbda489026f19bbf155f0ea82402d348b0aa4c03beaca" content-hash = "7295e9ec7f7492017c5bbda489026f19bbf155f0ea82402d348b0aa4c03beaca"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "gallery" name = "gallery"
version = "0.1.0" version = "0.2.1"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"
@@ -38,3 +38,5 @@ gallery = "gallery.main:run"
addopts = "-p no:warnings" addopts = "-p no:warnings"
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[tool.poetry_bumpversion.file."gallery/version.py"]

View File

@@ -2,4 +2,4 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
docker build -t shmyga/gallery . docker compose -f docker-compose-develop.yaml up --build --watch

45
scripts/docker-action Executable file
View File

@@ -0,0 +1,45 @@
#!/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
}
DEFAULT_TARGETS="."
TARGETS="${@-$DEFAULT_TARGETS}"
DOCKER_ACTION="${DOCKER_ACTION-build}"
for TARGET in $TARGETS; do
$DOCKER_ACTION "$TARGET"
done

6
scripts/locales Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
cd gallery/easel/route/view/locales/ru/LC_MESSAGES || exit
msgfmt messages.po

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
IMAGE_NAME=shmyga/gallery
docker tag $IMAGE_NAME instreamatic.com:8083/$IMAGE_NAME
docker push instreamatic.com:8083/$IMAGE_NAME

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
# docker run --rm -p 8000:80 shmyga/gallery
docker compose up --build

19
scripts/version Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
if [ -z "$1" ]; then
echo "Usage: $0 [version]"
exit 1
fi
if [[ ! -z "$(git status -s)" ]]; then
echo "Uncomitted changes"
exit 1
fi
poetry version $1
(cd static && npm version $1 --allow-same-version)
git add .
git commit -m "ci(version): $1"
git tag $1

2
static/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1
static/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

1234
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
static/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "gallery",
"version": "0.2.1",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"author": "shmyga <shmyga.z@gmail.com>",
"license": "ISC",
"description": "",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"sass": "^1.99.0"
},
"devDependencies": {
"vite": "^8.0.9"
}
}

17
static/src/components.ts Normal file
View File

@@ -0,0 +1,17 @@
class AppLinkElement extends HTMLElement {
static observedAttributes = ["icon", "href"];
constructor() {
super();
this.innerHTML = `
<a href="${this.getAttribute("href")}"
class="d-flex align-items-center text-body text-decoration-none">
<span class="fs-4">
<i class="bi bi-${this.getAttribute("icon")}"></i>
<span>${this.textContent}</span>
</span>
</a>`;
}
}
customElements.define("app-link", AppLinkElement);

5
static/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import "./main.scss";
import "bootstrap";
import "./theme";
import "./language";
import "./components";

62
static/src/language.ts Normal file
View File

@@ -0,0 +1,62 @@
(() => {
const getStoredLanguage = () => {
const m = document.cookie.match(/language=(\w+)/);
return m ? m[1] : null;
};
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
const getPreferredLanguage = () => {
const storedLanguage = getStoredLanguage();
if (storedLanguage) {
return storedLanguage;
}
const result = window.navigator.language.split("-")[0];
return ["en", "ru"].includes(result) ? result : "en";
};
const setLanguage = (language: string) => {};
setLanguage(getPreferredLanguage());
const showActiveLanguage = (language: string, focus = false) => {
const languageSwitcher = document.querySelector("#bd-language");
if (!languageSwitcher) {
return;
}
const languageSwitcherText = document.querySelector("#bd-language-text");
const activeLanguageIcon = document.querySelector(".language-icon-active");
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
const activeLanguageIconContent = btnToActive?.querySelector("span")?.textContent;
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
element.classList.remove("active");
element.setAttribute("aria-pressed", "false");
});
btnToActive.classList.add("active");
btnToActive.setAttribute("aria-pressed", "true");
activeLanguageIcon.textContent = activeLanguageIconContent;
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
if (focus) {
languageSwitcher.focus();
}
};
window.addEventListener("DOMContentLoaded", () => {
showActiveLanguage(getPreferredLanguage());
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const language = toggle.getAttribute("data-bs-language-value") || "";
setStoredLanguage(language);
setLanguage(language);
showActiveLanguage(language, true);
window.location.reload();
});
});
});
})();

19
static/src/main.scss Normal file
View File

@@ -0,0 +1,19 @@
@import "bootstrap/scss/bootstrap";
$bootstrap-icons-font-dir: "bootstrap-icons/font/fonts";
@import "bootstrap-icons/font/bootstrap-icons";
@import "./widget.scss";
@import "./weather.scss";
.table.table-compact {
td {
padding: 0.1rem 0.4rem;
}
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}

76
static/src/theme.ts Normal file
View File

@@ -0,0 +1,76 @@
(() => {
const getStoredTheme = () => localStorage.getItem("theme");
const setStoredTheme = (theme: string) => localStorage.setItem("theme", theme);
const getPreferredTheme = () => {
const storedTheme = getStoredTheme();
if (storedTheme) {
return storedTheme;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
const setTheme = (theme: string) => {
if (theme === "auto") {
document.documentElement.setAttribute(
"data-bs-theme",
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
);
} else {
document.documentElement.setAttribute("data-bs-theme", theme);
}
};
setTheme(getPreferredTheme());
const showActiveTheme = (theme: string, focus = false) => {
const themeSwitcher = document.querySelector("#bd-theme");
if (!themeSwitcher) {
return;
}
const themeSwitcherText = document.querySelector("#bd-theme-text");
const activeThemeIcon = document.querySelector(".theme-icon-active");
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
const activeThemeIconClass = btnToActive.querySelector("i.bi").className.match(/bi-[\w-]+/)[0];
document.querySelectorAll("[data-bs-theme-value]").forEach((element) => {
element.classList.remove("active");
element.setAttribute("aria-pressed", "false");
});
btnToActive.classList.add("active");
btnToActive.setAttribute("aria-pressed", "true");
const classesToRemove = Array.from(activeThemeIcon.classList).filter((className) => className.startsWith("bi-"));
activeThemeIcon.classList.remove(...classesToRemove);
activeThemeIcon.classList.add(activeThemeIconClass);
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
if (focus) {
themeSwitcher.focus();
}
};
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
const storedTheme = getStoredTheme();
if (storedTheme !== "light" && storedTheme !== "dark") {
setTheme(getPreferredTheme());
}
});
window.addEventListener("DOMContentLoaded", () => {
showActiveTheme(getPreferredTheme());
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const theme = toggle.getAttribute("data-bs-theme-value") || '';
setStoredTheme(theme);
setTheme(theme);
showActiveTheme(theme, true);
});
});
});
})();

73
static/src/weather.scss Normal file
View File

@@ -0,0 +1,73 @@
.table-weather {
.header {
font-size: 0.9rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
background: rgba(1, 0, 0, 0.1) !important;
}
.date.now {
background: rgba(0, 128, 255, 0.2) !important;
}
.date .value a {
all: unset;
cursor: pointer;
}
.cloudness {
vertical-align: top;
}
.cloudness .icon {
font-size: 1rem;
}
.cloudness .icon:first-child {
font-size: 2rem;
}
.temperature {
padding: 0;
}
.temperature .value {
padding: 0.1rem 0.4rem;
}
.temperature .value.positive {
color: orangered;
}
.temperature .value.negative {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure {
padding: 0 !important;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}
}

17
static/src/widget.scss Normal file
View File

@@ -0,0 +1,17 @@
.widget .app {
padding: 0.5rem !important;
}
.widget header {
display: none !important;
}
.widget main {
display: flex;
flex-direction: column;
align-items: center;
}
.widget footer {
display: none !important;
}

14
static/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import packageJson from "./package.json";
export default defineConfig({
base: "./",
build: {
outDir: "./dist",
lib: {
entry: "./src/index.ts",
name: packageJson.name,
fileName: (format) => `${packageJson.name}.${format}.js`,
},
},
});

0
tests/common/__init__.py Normal file
View File

17
tests/common/mock.py Normal file
View File

@@ -0,0 +1,17 @@
from pathlib import Path
from gallery.sketch.source import ApiSource
class MockSource(ApiSource):
def __init__(self, path: Path, mapping: dict[str, str]):
super().__init__("")
self._path = path
self._mapping = mapping
async def request(self, endpoint: str) -> str:
for pattern, filename in self._mapping.items():
if pattern in endpoint:
return (self._path / filename).read_text()
raise ValueError(endpoint)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
from pathlib import Path
from tests.common.mock import MockSource
GISMETEO_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"today": "today.html",
"10-days": "10-days.html",
"mq/city/q": "mq_city_q.json",
},
)

View File

@@ -0,0 +1,400 @@
{
"meta": { "status": true },
"data": [
{
"id": 4432,
"kind": "M",
"slug": "orel",
"coordinates": { "latitude": 52.968498, "longitude": 36.0695 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 13074,
"kind": "A",
"slug": "orel-yuzhnyy-im-i-s-turgeneva",
"coordinates": { "latitude": 52.935001, "longitude": 36.001671 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112316,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.0172, "longitude": 30.849199 },
"obsStationId": 12921,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1828, "slug": "loyev-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 178290,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 58.799999, "longitude": 34.453701 },
"obsStationId": 11657,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 248, "slug": "novgorod-oblast" },
"subdistrict": { "id": 2857, "slug": "municipal-district-khvoyninsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112830,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.182499, "longitude": 30.4349 },
"obsStationId": 12920,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1833, "slug": "rechytsa-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97816,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 49.088799, "longitude": 36.228401 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1646, "slug": "berestyn-district" },
"translations": {
"ru": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
},
"kk": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97619,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.980499, "longitude": 36.0075 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1649, "slug": "lozivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 78141,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.945999, "longitude": 35.689098 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1184, "slug": "samarivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 77735,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 48.587799, "longitude": 34.8111 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1178, "slug": "dniprovskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
},
"kk": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 171956,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 55.516499, "longitude": 44.0658 },
"obsStationId": 11899,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 266, "slug": "nizhny-novgorod-oblast" },
"subdistrict": { "id": 2796, "slug": "municipal-district-vadsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
}
],
"error": null
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
MATCHTV_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"test": "test.html",
},
)

File diff suppressed because one or more lines are too long

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