21 Commits

Author SHA1 Message Date
91e2c9d123 ci(version): 0.2.3 2026-06-10 11:49:56 +03:00
91d9c37612 build(vscode): add settings 2026-06-10 11:49:48 +03:00
b5f2c272bb feat(easel): update navbar 2026-06-10 11:37:49 +03:00
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
ad8144df37 feat(easel): add base template 2026-04-12 17:37:36 +03:00
f303d0e1f4 fix(gismeteo): fix gismeteo parser 2026-04-12 16:27:02 +03:00
3e80ccb0df feat(weather): add openweather api 2024-08-25 23:28:49 +03:00
114 changed files with 17695 additions and 12083 deletions

13
.editorconfig Normal file
View File

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

5
.env Normal file
View File

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

3
.gitignore vendored
View File

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

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:poetry",
"packageManager": "ms-python.python:poetry"
}
]
}

View File

@@ -1,23 +1,33 @@
FROM python:3.12 AS builder
ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH"
RUN apt update && \
apt install -y gettext
WORKDIR /app
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 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
ENV PATH="/app/.venv/bin:$PATH"
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"
COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
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"]

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

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,39 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import FastAPI, Request
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.version import __version__
from ..translation import _
class Section(NamedTuple):
link: str
title: str
icon: str
SECTIONS = [
Section("weather", "Погода"),
Section("schedule", "Телепрограмма"),
Section("weather", "Weather", "brightness-high"),
Section("schedule", "TV program", "tv"),
]
def mount(app: FastAPI):
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)
router = APIRouter()
templates = Jinja2Templates(directory=base_dir / "templates")
templates.env.globals.update({"_": _})
@router.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse(
request=request,
name="index.html",
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,104 +0,0 @@
/*
base
*/
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
/*
table
*/
table {
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
/*
a.button
*/
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
/*
app
*/
.app-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
}
.app-title {
display: flex;
justify-content: center;
flex-grow: 1;
}
.app-link-home > * {
margin-left: 2rem;
width: 2rem;
height: 2rem;
background-image: url("/static/common/gallery.png");
background-size: contain;
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}
ul.app-list {
list-style: none;
}
ul.app-list > li {
border: 1px solid lightgrey;
}
ul.app-list > li > a {
display: flex;
gap: 0.25rem;
padding: 0.5rem 2rem;
text-decoration: none;
color: inherit;
}
ul.app-list > li:hover {
border-color: blue;
}
ul.app-list > li:hover > a {
color: blue;
}

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="{{request.state.language}}">
<head>
{% block head %}
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet"
href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.es.js?v={{version}}"></script>
<link rel="icon"
href="/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
</head>
<body>
<div class="app col-lg-8 mx-auto p-3 py-md-5">
<header class="app-header pb-3 mb-5 border-bottom">
<div class="link-list">
<app-link href="/"
icon="gear">API Gallery</app-link>
{% block header %}{% endblock %}
</div>
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-language"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="{{_('Select language')}} (default)">
<span class="fi fir 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 fir 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 fir 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>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
if (widget) {
document.body.classList.add('widget');
}
}());
</script>
</body>
</html>

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@@ -1,40 +1,24 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
{% block title %}
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
{% block header %}
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
{% 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>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<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>
</div>
</h3>
<table>
</h4>
<table class="table">
<thead>
<tr>
<td></td>
@@ -44,7 +28,7 @@
</thead>
<tbody>
{% 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.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>
@@ -52,6 +36,4 @@
{% endfor %}
</tbody>
</table>
</body>
</html>
{% endblock %}

View File

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

View File

@@ -1,40 +1,25 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
{% block title %}
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>ТВ</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
{% block header %}
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
{% 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>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<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>
</div>
</h3>
<table class="{{'live' if live else ''}}">
</h4>
<div>
<table class="table">
<thead>
<tr>
<td></td>
@@ -46,15 +31,13 @@
{% for response in responses %}
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
{% if values|length > 0 %}
<tr>
<tr class="table-primary fs-4">
<td colspan="3">
<div class="title">{{response.channel.name}}</div>
<div>{{response.channel.name}}</div>
</td>
<td></td>
<td></td>
</tr>
{% 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.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>
@@ -64,6 +47,5 @@
{% endfor %}
</tbody>
</table>
</body>
</html>
</div>
{% endblock %}

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,84 +1,88 @@
import datetime
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from babel.dates import format_date
from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse
from gallery.version import __version__
from ..common.util import TagType, TagUtil
from ..translation import _
from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
templates = Jinja2Templates(
directory=[
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: Request, response: WeatherResponse):
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)
async def get_weather_list(request: Request):
weather_api: WeatherApi = request.app.state.weather_api
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations)
router = APIRouter()
@router.get("/weather", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather
locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"locations": locations_data,
"locations": locations,
},
)
@app.get("/weather/{location}", response_class=RedirectResponse)
@router.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)
async def get_weather_day_mock(request: Request):
response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
async def get_weather_days_mock(request: Request):
response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: Request, location: str, date: datetime.date):
weather_api: WeatherApi = request.app.state.weather_api
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather
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: Request, location: str, days: int):
weather_api: WeatherApi = request.app.state.weather_api
@router.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)
async def get_weather_tag(request: Request, location: str, tag: str):
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
weather_api: WeatherApi = request.app.state.weather_api
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:

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

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

View File

@@ -1,44 +1,29 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
</head>
{% block header %}
<app-link href="/weather"
icon="brightness-high">{{_("Weather")}}</app-link>
{% endblock %}
<body class="app-container">
<h3 class="app-header">
<a class="app-link-home"
href="/">
<div></div>
</a>
<div class="app-title">
{% block content %}
<h4>
{% 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}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<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}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<span>{{response.location}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
{% endif %}
</div>
</h3>
<table>
</h4>
<div class="table-responsive">
<table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody>
<!-- date -->
<tr>
@@ -51,9 +36,9 @@
{% endif %}
{% if response.period == 'days' %}
<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())}}">
{{value.date.strftime('%a %d')}}
{{format_date(value.date, 'E d', locale=request.state.language)}}
</a>
</span>
</td>
@@ -64,7 +49,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Облачность
{{_("Cloudiness")}}
</td>
</tr>
<tr>
@@ -80,7 +65,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Температура, °C
{{_("Temperature, °C")}}
</td>
</tr>
<tr>
@@ -99,7 +84,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Направление ветра
{{_("Wind direction")}}
</td>
</tr>
<tr>
@@ -113,7 +98,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Скорость ветра, м/с
{{_("Wind speed, m/s")}}
</td>
</tr>
<tr>
@@ -133,7 +118,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Осадки, мм
{{_("Precipitation, mm")}}
</td>
</tr>
<tr>
@@ -148,7 +133,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Давление, мм рт. ст.
{{_("Pressure, mmHg")}}
</td>
</tr>
<tr>
@@ -166,7 +151,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Влажность, %
{{_("Humidity, %")}}
</td>
</tr>
<tr>
@@ -179,6 +164,5 @@
</tr>
</tbody>
</table>
</body>
</html>
</div>
{% endblock %}

View File

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

View File

@@ -1,13 +1,13 @@
import datetime
import json
import logging
from typing import Any, Dict, List
from typing import Any
from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from . import datehelp
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:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +52,7 @@ class GismeteoApi(WeatherApi):
)
def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup)
@@ -69,11 +69,34 @@ class GismeteoApi(WeatherApi):
values=values,
)
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
async def find_locations(self, query: str) -> list[Location]:
geo = "ru"
latitude = 52.968498
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:
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1,94 @@
import datetime
import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv")
CHANNELS_MAP: dict[ChannelId, str] = {
ChannelId.MATCH_TV: "match-tv-49",
ChannelId.MATCH_IGRA: "match-igra-1174",
ChannelId.MATCH_ARENA: "match-arena-1173",
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
ChannelId.MATCH_STRANA: "match-strana-1356",
ChannelId.MATCH_PLANETA: "match-planeta-1177",
# ChannelId.EUROSPORT: "eurosport-677",
# ChannelId.EUROSPORT_2: "eurosport-2-720",
ChannelId.START: "start-103",
}
HEADERS: dict[str, str] = {
"Accept": (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,image/webp,"
"image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.9"
),
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Connection": "keep-alive",
"Host": "tv.yandex.ru",
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/100.0.4896.133 "
"Safari/537.36"
),
}
class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
async def get_channels(self) -> list[ChannelId]:
return list(CHANNELS_MAP.keys())
async def get_channel_schedule(
self, channel_id: ChannelId, date: datetime.date
) -> Schedule:
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser")
if soup.select_one(".CheckboxCaptcha") is not None:
raise RuntimeError("Captcha")
values = []
channel_name = soup.select_one(".channel-header__text").text.strip()
current_day = datetime.datetime.combine(
date.today(), datetime.datetime.min.time()
)
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in soup.select(".channel-schedule .channel-schedule__event"):
title = item.select_one(".channel-schedule__title").text.strip()
time_str = item.select_one(".channel-schedule__time").text.strip()
hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
current_day += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = item.select_one(".channel-schedule__info .icon_live") is not None
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
)

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,28 @@
import asyncio
import datetime
from ..api import Api
from .model import Schedule
from .model import ChannelId, Schedule
class ScheduleApi(Api):
async def get_channels(self) -> list[str]:
INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]:
raise NotImplementedError
async def get_channel_schedule(
self, channel_id: str, date: datetime.date
self, channel_id: ChannelId, date: datetime.date
) -> Schedule:
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 gallery.sketch.cached import CachedApi
from gallery.sketch.cached import CachedApi, CachePreset
from gallery.util import TimeUnit
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]):
@@ -13,20 +16,27 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
@cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_channels(self) -> list[str]:
async def get_channels(self) -> list[ChannelId]:
return await self._api.get_channels()
@cached(
key_builder=lambda fun, self, channel_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
),
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_channel_schedule(
self, channel_id: str, date: datetime.date
self, channel_id: ChannelId, date: datetime.date
) -> Schedule:
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 .model import Channel
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
from .model import Channel, ChannelId
BUNDLE = CatalogBundle(
[
@@ -27,5 +11,11 @@ BUNDLE = CatalogBundle(
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
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
from enum import StrEnum
from pydantic import BaseModel
@@ -8,8 +9,26 @@ class Model(BaseModel):
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):
id: str
id: ChannelId
name: str

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
from pathlib import Path
class TimeUnit:
SECOND = 1
MINUTE = 60 * SECOND
HOUR = 60 * MINUTE
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]]
name = "aiocache"
@@ -6,6 +6,7 @@ version = "0.12.2"
description = "multi backend asyncio cache"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"},
{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)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
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_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"},
@@ -112,7 +114,7 @@ multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0"
[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]]
name = "aiosignal"
@@ -120,6 +122,7 @@ version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["main", "app"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -145,6 +149,7 @@ version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
@@ -156,7 +161,7 @@ sniffio = ">=1.1"
[package.extras]
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)"]
[[package]]
@@ -165,6 +170,7 @@ version = "3.2.4"
description = "An abstract syntax tree for Python with inference support."
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25"},
{file = "astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a"},
@@ -176,6 +182,7 @@ version = "23.2.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
{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"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
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 ; 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]]
name = "beautifulsoup4"
@@ -195,6 +217,7 @@ version = "4.12.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.6.0"
groups = ["main"]
files = [
{file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
@@ -216,6 +239,7 @@ version = "24.4.2"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
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_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"},
@@ -250,7 +274,7 @@ platformdirs = ">=2"
[package.extras]
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)"]
uvloop = ["uvloop (>=0.15.2)"]
@@ -260,6 +284,7 @@ version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
groups = ["app"]
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
@@ -271,6 +296,7 @@ version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
groups = ["app", "dev"]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
@@ -285,10 +311,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["app", "dev", "test"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {app = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
[[package]]
name = "dateparser"
@@ -296,6 +324,7 @@ version = "1.2.0"
description = "Date parsing library designed to parse dates from HTML pages"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830"},
{file = "dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30"},
@@ -318,6 +347,7 @@ version = "0.3.8"
description = "serialize all of Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"},
{file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"},
@@ -333,6 +363,7 @@ version = "2.6.1"
description = "DNS toolkit"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"},
{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."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "fastapi-0.111.1-py3-none-any.whl", hash = "sha256:4f51cfa25d72f9fbc3280832e84b32494cf186f50158d364a8765aabf22587bf"},
{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. 🚀"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "fastapi_cli-0.0.4-py3-none-any.whl", hash = "sha256:a2552f3a7ae64058cdbb530be6fa6dbfc975dc165e4fa66d224c3d396e25e809"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["main"]
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_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"
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{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."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{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."
optional = false
python-versions = ">=3.8.0"
groups = ["app"]
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_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"},
@@ -576,6 +614,7 @@ version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
@@ -589,7 +628,7 @@ idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
@@ -600,6 +639,7 @@ version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
groups = ["main", "app"]
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
@@ -611,6 +651,7 @@ version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{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."
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{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."
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
{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!"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{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"},
@@ -677,6 +721,7 @@ version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.7"
groups = ["app"]
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_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
@@ -746,6 +791,7 @@ version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
groups = ["dev"]
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
@@ -757,6 +803,7 @@ version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -768,6 +815,7 @@ version = "6.0.5"
description = "multidict implementation"
optional = false
python-versions = ">=3.7"
groups = ["main"]
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_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."
optional = false
python-versions = ">=3.5"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
@@ -878,6 +927,7 @@ version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev", "test"]
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{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."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{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`."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["main", "app"]
files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["main", "app"]
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_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."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
@@ -1068,6 +1124,7 @@ version = "3.2.6"
description = "python code static checker"
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"},
{file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"},
@@ -1112,6 +1170,7 @@ version = "0.23.8"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"},
{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"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{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"},
@@ -1144,6 +1204,7 @@ version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{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"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
{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"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
{file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
@@ -1183,6 +1246,7 @@ version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
groups = ["app"]
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_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"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"},
{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."
optional = false
python-versions = ">=3.8"
groups = ["main"]
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_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"
optional = false
python-versions = ">=3.7.0"
groups = ["app"]
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
@@ -1364,6 +1431,7 @@ version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
{file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
@@ -1375,6 +1443,7 @@ version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
groups = ["main"]
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{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"
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{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."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
{file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
@@ -1408,6 +1479,7 @@ version = "0.37.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
@@ -1425,6 +1497,7 @@ version = "0.13.0"
description = "Style preserving TOML library"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264"},
{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."
optional = false
python-versions = ">=3.7"
groups = ["app"]
files = [
{file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"},
{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+"
optional = false
python-versions = ">=3.8"
groups = ["main", "app"]
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{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"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
{file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
@@ -1475,6 +1552,7 @@ version = "5.2"
description = "tzinfo object for the local timezone"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"},
{file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"},
@@ -1492,6 +1570,7 @@ version = "0.30.3"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
{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\""}
python-dotenv = {version = ">=0.13", 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\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[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]]
name = "uvloop"
@@ -1517,6 +1596,8 @@ version = "0.19.0"
description = "Fast implementation of asyncio event loop on top of libuv"
optional = false
python-versions = ">=3.8.0"
groups = ["app"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
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_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"},
@@ -1553,7 +1634,7 @@ files = [
[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)"]
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]]
name = "watchfiles"
@@ -1561,6 +1642,7 @@ version = "0.22.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.8"
groups = ["app"]
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_11_0_arm64.whl", hash = "sha256:61af9efa0733dc4ca462347becb82e8ef4945aba5135b1638bfc20fad64d4f0e"},
@@ -1648,6 +1730,7 @@ version = "12.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.8"
groups = ["app"]
files = [
{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"},
@@ -1729,6 +1812,7 @@ version = "1.9.4"
description = "Yet another URL library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
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_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
@@ -1827,6 +1911,6 @@ idna = ">=2.0"
multidict = ">=4.0"
[metadata]
lock-version = "2.0"
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "7295e9ec7f7492017c5bbda489026f19bbf155f0ea82402d348b0aa4c03beaca"
content-hash = "39107ded0683832d1ca3d637dc50fac7dd441375ead30cdb56d64edee13c97e2"

View File

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

View File

@@ -0,0 +1,15 @@
@use "flag-icons/sass/flag-icons" with (
$flag-icons-path: "flag-icons/flags",
$flag-icons-included-countries: (
"gb",
"ru",
"by",
"ua",
"kz",
)
);
.fir {
@extend .fis;
border-radius: 50%;
}

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

@@ -0,0 +1,42 @@
@import "./lib/flag-icons";
@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;
}
.app-header {
display: flex;
flex-direction: row;
> .link-list {
display: flex;
flex-direction: row;
gap: 1rem;
app-link {
display: flex;
align-items: center;
&:not(:last-child)::after {
margin-left: 1rem;
content: "|";
}
}
}
}
@import "./mobile";

34
static/src/mobile.scss Normal file
View File

@@ -0,0 +1,34 @@
@import "./lib/bootstrap";
@include media-breakpoint-down(md) {
.app-header {
flex-direction: column;
> .link-list {
border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
padding-bottom: map-get($spacers, 1);
margin-bottom: map-get($spacers, 1);
flex-direction: column;
gap: 0;
app-link {
display: flex;
align-items: center;
&:not(:last-child)::after {
content: "";
}
}
}
}
.navbar-nav {
flex-direction: column !important;
> .nav-item {
.btn {
padding: 0.125rem !important;
}
}
}
}

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 !important;
}
.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)

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