15 Commits

Author SHA1 Message Date
eec72c77ab docs: add screenshot 2026-05-07 01:11:42 +03:00
160ec2b48b ci(version): 0.2.2 2026-04-24 14:12:39 +03:00
7c57f939c0 feat(static): update and optimizate 2026-04-24 14:12:14 +03:00
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
100 changed files with 15766 additions and 11800 deletions

View File

@@ -6,6 +6,7 @@ indent_style = space
indent_size = 2 indent_size = 2
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
max_line_length = 120
[*.md] [*.md]
max_line_length = off max_line_length = off

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

@@ -1,23 +1,33 @@
FROM python:3.12 AS builder FROM python:3.12 AS builder
ENV POETRY_HOME="/opt/poetry" ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH" ENV PATH="$POETRY_HOME/bin:$PATH"
RUN apt update && \
apt install -y gettext
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
COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
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 && \
apt install -y locales && \
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales
ENV LANG=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/
#COPY --from=builder /app/gallery/easel/route/view/locales /app/gallery/easel/route/view/locales
COPY --from=builder --parents locales/**/*.mo ./
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,13 @@
# Gallery # API Gallery
Weather and TV program API
![API Gallery](docs/screenshot.png "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:

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View File

@@ -33,12 +33,14 @@
"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,23 +1,22 @@
import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, doc, view from .route import api, doc
from .route.view import router as view_router
DEFAULT_LOCALE = "ru_RU.UTF-8"
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI: def build_app(api_bundle: ApiBundle) -> FastAPI:
_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.api = api_bundle app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) doc.mount(app)
api.mount(app) api.mount(app)
view.mount(app) app.include_router(view_router)
return app return 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

@@ -3,23 +3,25 @@ import datetime
from fastapi import FastAPI from fastapi import FastAPI
from gallery.easel.core import AppRequest 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: AppRequest) -> list[str]: async def get_api_weather_locations(
request: AppRequest, query: str
) -> list[Location]:
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
return await weather_api.get_locations() 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: AppRequest, location: str, date: datetime.date request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse: ) -> WeatherResponse:
weather_api = request.app.state.api.weather 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: AppRequest, location: str, days: int request: AppRequest, location: str, days: int
) -> WeatherResponse: ) -> WeatherResponse:

View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI from fastapi import APIRouter, Depends
from . import common, schedule, weather 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)])
def mount(app: FastAPI): router.include_router(common_router)
common.mount(app) router.include_router(weather_router)
weather.mount(app) router.include_router(schedule_router)
schedule.mount(app)

View File

@@ -1,37 +1,41 @@
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({"_": _})
return templates.TemplateResponse(
request=request,
name="root_index.html", @router.get("/", response_class=HTMLResponse)
context={ async def get_section_list(request: Request):
"version": __version__, return templates.TemplateResponse(
"sections": SECTIONS, request=request,
}, name="root_index.html",
) context={
"version": __version__,
"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,112 +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: row;
flex-wrap: nowrap;
align-items: stretch;
}
.app-menu {
display: flex;
flex-direction: column;
margin: 0.5rem;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
gap: 0.5rem;
justify-content: center;
}
.app-link-home > * {
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;
padding-left: 0;
}
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

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{request.state.language}}">
<head> <head>
{% block head %} {% block head %}
@@ -10,29 +10,115 @@
content="ie=edge"> content="ie=edge">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" <link rel="stylesheet"
href="/static/common/style.css?v={{version}}"> href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.es.js?v={{version}}"></script>
<link rel="icon" <link rel="icon"
href="/static/common/favicon.ico?v={{version}}" href="/favicon.ico?v={{version}}"
type="image/x-icon"> type="image/x-icon">
{% endblock %} {% endblock %}
</head> </head>
<body class="app-container"> <body>
<div class="app-menu"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
<a class="app-link-home" <header class="d-flex align-items-center pb-3 mb-5 border-bottom">
href="/"> <app-link href="/"
<div></div> icon="gear">API Gallery</app-link>
</a> {% block header %}{% endblock %}
</div> <ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<div class="app-content"> <li class="nav-item dropdown">
<h3 class="app-header"> <button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
{% block header %}{% endblock %}</span> id="bd-language"
</h3> type="button"
{% block content %}{% endblock %} aria-expanded="false"
<div class="app-footer"> data-bs-toggle="dropdown"
{% block footer %}{% endblock %} aria-label="{{_('Select language')}} (default)">
</div> <span class="fi fi-gb 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="fi fi-gb me-2 language-icon-active"></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="fi fi-ru me-2 language-icon-active"></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)">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon-active"></span>
<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">
<span class="bi bi-sun-fill me-2 opacity-50 theme-icon"></span>
{{_("Light")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false">
<span class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></span>
{{_("Dark")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto"
aria-pressed="true">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon"></span>
{{_("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> </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> </body>
</html> </html>

View File

@@ -1,21 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Информация{% endblock %} {% block title %}{{_("Index")}}{% endblock %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block header %}Информация{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("View")}}</h1>
<div class="list-group mb-5">
{% for section in sections %} {% for section in sections %}
<li> <a href="{{section.link}}"
<a href="{{section.link}}"> class="list-group-item list-group-item-action px-4">
<span class="icon" <app-link href="{{section.link}}"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span> icon="{{section.icon}}">
<span>{{section.title}}</span> {{_(section.title)}}
</a> </app-link>
</li> </a>
{% endfor %} {% endfor %}
</ul> </div>
<hr class="col-3 col-md-2 mb-5">
<h1>{{_("Docs")}}</h1>
<a href="/docs"
target="_blank">
<h4>Swagger</h4>
</a>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,9 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from babel.dates import format_date
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.easel.core import AppRequest from gallery.easel.core import AppRequest
@@ -11,76 +11,82 @@ 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(
{
"_": _,
"version": __version__,
"format_date": format_date,
}
)
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( @router.get("/schedule", response_class=HTMLResponse)
directory=[ async def get_schedule_list(request: AppRequest):
base_dir.parent / "common/templates", schedule_api = request.app.state.api.schedule
base_dir / "templates", channels = await schedule_api.get_channels()
] channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"channels": channels_data,
},
) )
templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"channels": channels_data,
},
)
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse) @router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False): async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels() results = await schedule_api.get_all_schedules(tag_value.date)
responses = [ return templates.TemplateResponse(
await schedule_api.get_channel_schedule(channel, tag_value.date) request=request,
for channel in channels name="schedule.html",
] context={
return templates.TemplateResponse( "version": __version__,
request=request, "tag_util": TagUtil,
name="schedule.html", "datetime": datetime,
context={ "response": results[0],
"version": __version__, "responses": results,
"tag_util": TagUtil, "live": live,
"datetime": datetime, },
"channels": channels, )
"response": responses[0],
"responses": responses,
"live": live,
},
)
@app.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse) @router.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str): async def get_channel_default(channel: str):
tag_value = TagUtil.parse_tag(tag) return RedirectResponse(f"{channel}/tag/today")
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) @router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
else: async def get_channel_tag(request: AppRequest, channel: str, tag: str):
raise ValueError(tag) tag_value = TagUtil.parse_tag(tag)
return templates.TemplateResponse( schedule_api = request.app.state.api.schedule
request=request, if tag_value.type == TagType.DAY:
name="channel.html", response = await schedule_api.get_channel_schedule(channel, tag_value.date)
context={ else:
"version": __version__, raise ValueError(tag)
"tag_util": TagUtil, return templates.TemplateResponse(
"datetime": datetime, request=request,
"response": response, name="channel.html",
}, context={
) "version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)

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,28 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}} {{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <span class="fs-4 text-body ms-2 me-2">/</span>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> <app-link href="/schedule"
<a class="button" icon="tv">{{_("TV program")}}</app-link>
href="../..">⬆️</a>
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<table> <h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../..">⬆️</a>
<span>{{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
</h4>
<table class="table">
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
@@ -32,7 +29,7 @@
</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>

View File

@@ -1,21 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}ТВ{% endblock %} {% block title %}{{_("TV program")}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Телепрограмма{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("TV program")}}</h1>
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li> <div class="list-group mb-5">
{% for channel in channels %} <a href="schedule/tag/today"
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li> class="list-group-item list-group-item-action px-4">
{% endfor %} <span class="fw-bold">Все</span>
</ul> </a>
{% for channel in channels %}
<a href="schedule/{{channel.id}}"
class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span>
</a>
{% endfor %}
</div>
{% endblock %} {% endblock %}

View File

@@ -1,29 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}} {{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <span class="fs-4 text-body ms-2 me-2">/</span>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> <app-link href="/schedule"
<a class="button" icon="tv">{{_("TV program")}}</app-link>
href="..">⬆️</a>
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="..">⬆️</a>
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
</h4>
<div> <div>
<table class="{{'live' if live else ''}}"> <table class="table">
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
@@ -35,15 +32,13 @@
{% 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>
</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>

View File

@@ -0,0 +1,35 @@
import gettext
from contextvars import ContextVar
from fastapi import Cookie, Header, Request
from gallery.util import root_path
_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=root_path / "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,93 +1,92 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI 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 babel.dates import format_date
from gallery.easel.core import AppRequest 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( base_dir.parent / "common/templates",
directory=[ base_dir / "templates",
base_dir.parent / "common/templates", ]
base_dir / "templates", )
] templates.env.globals.update(
{
"_": _,
"version": __version__,
"format_date": format_date,
}
)
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
) )
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@app.get("/weather", response_class=HTMLResponse) router = APIRouter()
async def get_weather_list(request: AppRequest):
weather_api = request.app.state.api.weather
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations)
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"locations": locations_data,
},
)
@app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse) @router.get("/weather", response_class=HTMLResponse)
async def get_weather_day_mock(request: AppRequest): async def get_weather_index(request: AppRequest, query: str | None = None):
response = WEATHER_MOCK_DATA.get_response("day") weather_api = request.app.state.api.weather
return build_weather_response(request, response) locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"locations": locations,
},
)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
async def get_weather_days_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse) @router.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date): async def get_weather_default(location: str):
weather_api = request.app.state.api.weather return RedirectResponse(f"{location}/tag/today")
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
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)
return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse) @router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str): async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
tag_value = TagUtil.parse_tag(tag) weather_api = request.app.state.api.weather
weather_api = request.app.state.api.weather response = await weather_api.get_day(location, date)
if tag_value.type == TagType.DAY: return build_weather_response(request, response)
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:
response = await weather_api.get_days(location, tag_value.days) @router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
else: async def get_weather_days(request: AppRequest, location: str, days: int):
raise ValueError(tag) weather_api = request.app.state.api.weather
return build_weather_response(request, response) response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@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)
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:
response = await weather_api.get_days(location, tag_value.days)
else:
raise ValueError(tag)
return build_weather_response(request, response)

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,20 +1,74 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода{% endblock %} {% block title %}{{_("Weather")}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Погода{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("Weather")}}</h1>
<form action=""
method="get"
class="mb-4">
<div class="input-group mb-3">
<input type="text"
class="form-control"
id="query"
name="query"
placeholder="{{_('Enter the city name')}}">
<button class="btn btn-primary"
type="submit">{{_("Search")}}</button>
</div>
</form>
<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="fi fi-{{location.country_code}} me-1"></span>
<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>
<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);
}
}
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 %} {% endblock %}

View File

@@ -1,32 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %} {% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %} {% block header %}
{% if response.period == 'day' %} <span class="fs-4 text-body ms-2 me-2">/</span>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../tag/days-10">⬆️</a>
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div> <h4>
<table style="margin: auto;"> {% if response.period == 'day' %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../tag/days-10">⬆️</a>
<span>{{response.location}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
{% endif %}
</h4>
<div class="table-responsive">
<table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody> <tbody>
<!-- date --> <!-- date -->
<tr> <tr>
@@ -39,9 +36,9 @@
{% endif %} {% endif %}
{% if response.period == 'days' %} {% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}"> <td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value"> <span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}"> <a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
{{value.date.strftime('%a %d')}} {{format_date(value.date, 'E d', locale=request.state.language)}}
</a> </a>
</span> </span>
</td> </td>
@@ -52,7 +49,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Облачность {{_("Cloudiness")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -68,7 +65,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Температура, °C {{_("Temperature, °C")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -87,7 +84,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Направление ветра {{_("Wind direction")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -101,7 +98,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Скорость ветра, м/с {{_("Wind speed, m/s")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -121,7 +118,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Осадки, мм {{_("Precipitation, mm")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -136,7 +133,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Давление, мм рт. ст. {{_("Pressure, mmHg")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -154,7 +151,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Влажность, % {{_("Humidity, %")}}
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -7,12 +7,14 @@ 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.openweather.api import OpenWeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.sketch.bundle import ApiBundle 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
api = ApiBundle( api = ApiBundle(
[ [
CachedScheduleApi(YandexTvApi()),
CachedScheduleApi(MatchTvApi()), CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()), CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()), CachedWeatherApi(OpenWeatherApi()),
@@ -24,8 +26,8 @@ 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,34 @@ 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"],
country_code=item["country"]["code"].lower(),
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)}")

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,7 +26,7 @@ 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"tvguide/{channel_id}?date={date:%Y%m%d}" endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
data = await self.SOURCE.request(endpoint) data = await self.SOURCE.request(endpoint)
@@ -46,8 +45,12 @@ class MatchTvApi(ScheduleApi):
for item in soup.select( for item in soup.select(
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission" ".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
): ):
title = item.select_one(".p-tv-guide-schedule-channel-transmission__title").text.strip() title = item.select_one(
time_str = item.select_one(".p-tv-guide-schedule-channel-transmission__time-block").text.strip() ".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

@@ -5,8 +5,7 @@ from collections import defaultdict
from aiocache import cached from aiocache import cached
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE, LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit from gallery.util import TimeUnit
@@ -20,11 +19,9 @@ class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather" PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
async def get_locations(self) -> list[str]: @classmethod
return [ def _parse_location(cls, location_id: str) -> tuple[float, float]:
LocationId.OREL, return tuple(map(float, location_id.split(":", maxsplit=2)))
LocationId.ZMIYEVKA,
]
@cached( @cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast", key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
@@ -32,8 +29,10 @@ class OpenWeatherApi(WeatherApi):
ttl=TimeUnit.DAY, ttl=TimeUnit.DAY,
) )
async def _get_location_forecast(self, location_id: str) -> Forecast: async def _get_location_forecast(self, location_id: str) -> Forecast:
location = BUNDLE.get_item(location_id) return await self.SOURCE.get_forecast(*self._parse_location(location_id))
return await self.SOURCE.get_forecast(location.lat, location.lon)
async def find_locations(self, query: str) -> list[Location]:
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:
data: Forecast = await self._get_location_forecast(location_id) data: Forecast = await self._get_location_forecast(location_id)
@@ -42,9 +41,8 @@ class OpenWeatherApi(WeatherApi):
value = FORECAST_ITEM_PARSER.parse(item) value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date: if value.date.date() == date:
values.append(value) values.append(value)
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location_id,
date=date, date=date,
period="day", period="day",
values=values, values=values,
@@ -61,9 +59,8 @@ class OpenWeatherApi(WeatherApi):
merge_weather_values(date, values) merge_weather_values(date, values)
for date, values in values_by_date.items() for date, values in values_by_date.items()
] ]
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location_id,
date=datetime.date.today(), date=datetime.date.today(),
period="days", period="days",
values=list(sorted(values, key=lambda item: item.date)), values=list(sorted(values, key=lambda item: item.date)),

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,5 +1,3 @@
from typing import Type
from .api import API, Api from .api import API, Api
from .schedule.api import ScheduleApi from .schedule.api import ScheduleApi
from .weather.api import WeatherApi from .weather.api import WeatherApi
@@ -15,7 +13,7 @@ class ApiBundle(list[Api]):
return value return value
raise ValueError(provider) raise ValueError(provider)
def get_api_by_type(self, api_type: Type[API]) -> API: def get_api_by_type(self, api_type: type[API]) -> API:
for value in self: for value in self:
if isinstance(value, api_type): if isinstance(value, api_type):
return value return value

View File

@@ -1,13 +1,19 @@
from typing import Generic from typing import Generic, NamedTuple
from gallery.util import TimeUnit from gallery.util import TimeUnit
from .api import API, Api from .api import API, 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

@@ -1,17 +0,0 @@
import json
class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_text(self, key: str) -> str:
return (self._data_dir / f"{key}").read_text()
def get_html(self, key: str) -> str:
return self.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads(self.get_text(f"{key}.json"))
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,31 +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="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

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

@@ -14,6 +14,10 @@ class Location(Model):
name: str name: str
lat: float lat: float
lon: float lon: float
country: str
country_code: str
district: str
subdistrict: str
class Cloudness(str, Enum): class Cloudness(str, Enum):

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.2"

View File

@@ -0,0 +1,77 @@
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 "Index"
msgstr "Содержание"
msgid "View"
msgstr "Просмотр"
msgid "Docs"
msgstr "Документация"
msgid "Toggle theme"
msgstr "Переключить тему"
msgid "Light"
msgstr "Светлая"
msgid "Dark"
msgstr "Тёмная"
msgid "Auto"
msgstr "Авто"
msgid "Select language"
msgstr "Выберите язык"
msgid "English"
msgstr "Английский"
msgid "Russian"
msgstr "Русский"
# weather
msgid "Weather"
msgstr "Погода"
msgid "Enter the city name"
msgstr "Введите название города"
msgid "Search"
msgstr "Поиск"
msgid "Cloudiness"
msgstr "Облачность"
msgid "Temperature, °C"
msgstr "Температура, °C"
msgid "Wind direction"
msgstr "Направление ветра"
msgid "Wind speed, m/s"
msgstr "Скорость ветра, м/с"
msgid "Precipitation, mm"
msgstr "Осадки, мм"
msgid "Pressure, mmHg"
msgstr "Давление, мм рт. ст."
msgid "Humidity, %"
msgstr "Влажность, %"
# tv
msgid "TV program"
msgstr "Телепрограмма"
msgid "Live broadcasts"
msgstr "Прямые трансляции"

108
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,23 @@ 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]]
name = "babel"
version = "2.18.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"},
{file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"},
]
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
@@ -195,6 +217,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 +239,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 +274,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 +284,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 +296,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 +311,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 +324,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 +347,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 +363,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 +384,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 +400,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 +426,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 +444,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 +531,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 +543,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 +565,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 +614,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 +628,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 +639,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 +651,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 +663,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 +678,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 +696,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 +721,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 +791,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 +803,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 +815,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 +915,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 +927,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 +939,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 +951,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 +968,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 +984,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 +1007,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 +1109,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 +1124,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 +1149,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 +1170,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 +1189,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 +1204,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 +1219,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 +1234,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 +1246,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 +1307,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 +1323,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 +1412,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 +1431,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 +1443,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 +1455,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 +1467,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 +1479,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 +1497,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 +1509,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 +1527,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 +1539,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 +1552,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 +1570,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 +1583,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 +1596,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 +1634,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 +1642,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 +1730,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 +1812,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 +1911,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 = "39107ded0683832d1ca3d637dc50fac7dd441375ead30cdb56d64edee13c97e2"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "gallery" name = "gallery"
version = "0.1.0" version = "0.2.2"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"
@@ -17,6 +17,7 @@ aiocache = {extras = ["redis"], version = "^0.12.2"}
[tool.poetry.group.app.dependencies] [tool.poetry.group.app.dependencies]
fastapi = "^0.111.1" fastapi = "^0.111.1"
jinja2 = "^3.1.4" jinja2 = "^3.1.4"
babel = "^2.18.0"
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
pytest = "^8.3.1" pytest = "^8.3.1"
@@ -38,3 +39,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"]

5
scripts/develop Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker compose -f docker-compose-develop.yaml up --build --watch

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

View File

@@ -2,4 +2,5 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
docker build -t shmyga/gallery . cd 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

1241
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
static/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "gallery",
"version": "0.2.2",
"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",
"flag-icons": "^7.5.0",
"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">
<span class="bi bi-${this.getAttribute("icon")} me-1"></span>
<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";

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

@@ -0,0 +1,64 @@
(() => {
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 activeLanguageIconClass = btnToActive.querySelector(".fi").className.match(/fi-[\w-]+/)[0];
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");
const classesToRemove = Array.from(activeLanguageIcon.classList).filter((className) => className.startsWith("fi-"));
activeLanguageIcon.classList.remove(...classesToRemove);
activeLanguageIcon.classList.add(activeLanguageIconClass);
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();
});
});
});
})();

40
static/src/lib/bootstrap-icons.scss vendored Normal file
View File

@@ -0,0 +1,40 @@
//$bootstrap-icons: "circle-half", "moon-stars-fill", "brightness-high", "gear", "sun-fill", "tv";
.bi {
display: inline-block;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
width: 1em;
height: 1em;
mask-size: contain;
mask-position: 50%;
mask-repeat: no-repeat;
background-color: currentColor;
//@each $icon in $bootstrap-icons {
// &.bi-#{$icon} {
// mask-image: url("bootstrap-icons/icons/#{$icon}.svg");
// }
///
&.bi-circle-half {
mask-image: url(bootstrap-icons/icons/circle-half.svg);
}
&.bi-moon-stars-fill {
mask-image: url(bootstrap-icons/icons/moon-stars-fill.svg);
}
&.bi-brightness-high {
mask-image: url(bootstrap-icons/icons/brightness-high.svg);
}
&.bi-gear {
mask-image: url(bootstrap-icons/icons/gear.svg);
}
&.bi-sun-fill {
mask-image: url(bootstrap-icons/icons/sun-fill.svg);
}
&.bi-tv {
mask-image: url(bootstrap-icons/icons/tv.svg);
}
}

52
static/src/lib/bootstrap.scss vendored Normal file
View File

@@ -0,0 +1,52 @@
@import "bootstrap/scss/mixins/banner";
@include bsBanner("");
// scss-docs-start import-stack
// Configuration
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
// Layout & components
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
//@import "bootstrap/scss/images";
//@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
//@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
//@import "bootstrap/scss/button-group";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
//@import "bootstrap/scss/card";
//@import "bootstrap/scss/accordion";
//@import "bootstrap/scss/breadcrumb";
//@import "bootstrap/scss/pagination";
//@import "bootstrap/scss/badge";
//@import "bootstrap/scss/alert";
//@import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
//@import "bootstrap/scss/close";
//@import "bootstrap/scss/toasts";
//@import "bootstrap/scss/modal";
//@import "bootstrap/scss/tooltip";
//@import "bootstrap/scss/popover";
//@import "bootstrap/scss/carousel";
//@import "bootstrap/scss/spinners";
//@import "bootstrap/scss/offcanvas";
//@import "bootstrap/scss/placeholders";
// Helpers
@import "bootstrap/scss/helpers";
// Utilities
@import "bootstrap/scss/utilities/api";
// scss-docs-end import-stack

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

@@ -0,0 +1,23 @@
@use "flag-icons/sass/flag-icons" with (
$flag-icons-path: "flag-icons/flags",
$flag-icons-included-countries: ("gb", "ru", "by", "ua", "kz")
);
@import "./lib/bootstrap";
@import "./lib/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(".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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -3,20 +3,21 @@ import datetime
import pytest import pytest
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.gismeteo.mock import GISMETEO_MOCK_DATA from tests.data.gismeteo import GISMETEO_MOCK_SOURCE
@pytest.fixture(name="gismeteo_api", scope="module") @pytest.fixture(name="gismeteo_api", scope="module")
def gismeteo_api_fixture() -> GismeteoApi: def gismeteo_api_fixture() -> GismeteoApi:
class MockSource:
async def request(self, endpoint: str):
return GISMETEO_MOCK_DATA.get_html(endpoint.split("/")[-1])
api = GismeteoApi() api = GismeteoApi()
api.SOURCE = MockSource() api.SOURCE = GISMETEO_MOCK_SOURCE
return api return api
async def test_search(gismeteo_api: GismeteoApi):
result = await gismeteo_api.find_locations("test")
assert len(result) == 10
async def test_day(gismeteo_api: GismeteoApi): async def test_day(gismeteo_api: GismeteoApi):
result = await gismeteo_api.get_day("test", datetime.date.today()) result = await gismeteo_api.get_day("test", datetime.date.today())
assert len(result.values) == 8 assert len(result.values) == 8

View File

@@ -3,21 +3,20 @@ import datetime
import pytest import pytest
from gallery.painting.matchtv.api import MatchTvApi from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.matchtv.mock import MATCHTV_MOCK_DATA from gallery.sketch.schedule.model import ChannelId
from tests.data.matchtv import MATCHTV_MOCK_SOURCE
@pytest.fixture(name="matchtv_api", scope="module") @pytest.fixture(name="matchtv_api", scope="module")
def matchtv_api_fixture() -> MatchTvApi: def matchtv_api_fixture() -> MatchTvApi:
class MockSource:
async def request(self, endpoint: str):
return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1])
api = MatchTvApi() api = MatchTvApi()
api.SOURCE = MockSource() api.SOURCE = MATCHTV_MOCK_SOURCE
return api return api
async def test_channel(matchtv_api: MatchTvApi): async def test_channel(matchtv_api: MatchTvApi):
result = await matchtv_api.get_channel_schedule("test", datetime.date.today()) result = await matchtv_api.get_channel_schedule(
ChannelId.TEST, datetime.date.today()
)
assert result is not None assert result is not None
assert len(result.values) > 0 assert len(result.values) > 0

View File

@@ -3,25 +3,27 @@ import datetime
import pytest import pytest
from gallery.painting.openweather.api import OpenWeatherApi from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.openweather.mock import OPENWEATHER_MOCK_DATA from gallery.painting.openweather.openweather import OpenWeather
from gallery.painting.openweather.openweather import Forecast from tests.data.openweather import OPENWEATHER_MOCK_SOURCE
@pytest.fixture(name="openweather_api", scope="module") @pytest.fixture(name="openweather_api", scope="module")
def openweather_api_fixture() -> OpenWeatherApi: def openweather_api_fixture() -> OpenWeatherApi:
async def _get_location_forecast(location_id: str) -> Forecast: class MockOpenWeather(OpenWeather):
return Forecast(**OPENWEATHER_MOCK_DATA.get_json("forecast")) def __init__(self):
super().__init__("")
self._source = OPENWEATHER_MOCK_SOURCE
api = OpenWeatherApi() api = OpenWeatherApi()
api._get_location_forecast = _get_location_forecast api.SOURCE = MockOpenWeather()
return api return api
async def test_day(openweather_api: OpenWeatherApi): async def test_day(openweather_api: OpenWeatherApi):
result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23)) result = await openweather_api.get_day("52.968498:36.0695", datetime.date(2024, 8, 23))
assert len(result.values) == 8 assert len(result.values) == 8
async def test_days(openweather_api: OpenWeatherApi): async def test_days(openweather_api: OpenWeatherApi):
result = await openweather_api.get_days("orel-4432", 10) result = await openweather_api.get_days("52.968498:36.0695", 10)
assert len(result.values) == 6 assert len(result.values) == 6

View File

@@ -0,0 +1,23 @@
import datetime
import pytest
from gallery.painting.yandextv.api import CHANNELS_MAP, YandexTvApi
from gallery.sketch.schedule.model import ChannelId
from tests.data.yandextv import YANDEXTV_MOCK_SOURCE
@pytest.fixture(name="yandextv_api", scope="module")
def yandextv_api_fixture() -> YandexTvApi:
api = YandexTvApi()
api.SOURCE = YANDEXTV_MOCK_SOURCE
CHANNELS_MAP[ChannelId("test")] = "test"
return api
async def test_channel(yandextv_api: YandexTvApi):
result = await yandextv_api.get_channel_schedule(
ChannelId.TEST, datetime.date.today()
)
assert result is not None
assert len(result.values) > 0