Compare commits
13 Commits
d3ef03a6a0
...
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c3b3aeafc | |||
| d1592150fd | |||
| 9351b9f53a | |||
| ecb574e286 | |||
| 94870a5c86 | |||
| 3dd0a5410c | |||
| a0e6f30e3b | |||
| 29fa6435ce | |||
| a886322d0e | |||
| 6112147b40 | |||
| ad8144df37 | |||
| f303d0e1f4 | |||
| 3e80ccb0df |
13
.editorconfig
Normal file
13
.editorconfig
Normal 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
5
.env
Normal 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
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
.pytest_cache
|
||||
.venv
|
||||
#.vscode
|
||||
static/node_modules
|
||||
static/dist
|
||||
16
Dockerfile
16
Dockerfile
@@ -3,21 +3,31 @@ ENV POETRY_HOME="/opt/poetry"
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
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
|
||||
|
||||
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 && \
|
||||
apt install -y locales gettext && \
|
||||
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/
|
||||
RUN cd gallery/easel/route/view/locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||
|
||||
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||
|
||||
10
README.md
10
README.md
@@ -1 +1,9 @@
|
||||
# Gallery
|
||||
# API Gallery
|
||||
|
||||
## View
|
||||
|
||||
https://api.shmyga.ru
|
||||
|
||||
## Swagger
|
||||
|
||||
https://api.shmyga.ru/docs
|
||||
|
||||
25
docker-compose-develop.yaml
Normal file
25
docker-compose-develop.yaml
Normal 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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
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
|
||||
|
||||
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
||||
|
||||
|
||||
def build_app(
|
||||
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
|
||||
) -> FastAPI:
|
||||
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
|
||||
_locale.setlocale(_locale.LC_TIME, locale)
|
||||
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
15
gallery/easel/core.py
Normal 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
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from . import schedule, weather
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
weather.mount(app)
|
||||
schedule.mount(app)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
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"),
|
||||
]
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_section_list(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
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="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 |
@@ -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;
|
||||
}
|
||||
124
gallery/easel/route/view/common/templates/base.html
Normal file
124
gallery/easel/route/view/common/templates/base.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{request.state.language}}">
|
||||
|
||||
<head>
|
||||
{% block head %}
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible"
|
||||
content="ie=edge">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet"
|
||||
href="/static/gallery.css?v={{version}}">
|
||||
<script type="module"
|
||||
src="/static/gallery.es.js?v={{version}}"></script>
|
||||
<link rel="icon"
|
||||
href="/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
||||
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
|
||||
<app-link href="/"
|
||||
icon="gear">API Gallery</app-link>
|
||||
{% block header %}{% endblock %}
|
||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-language"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Select language (default)">
|
||||
<span class="me-2 language-icon-active">🇬🇧</span>
|
||||
<span class="d-lg-none ms-2"
|
||||
id="bd-language-text">Select language</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end"
|
||||
aria-labelledby="bd-language-text">
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="en"
|
||||
aria-pressed="false">
|
||||
<span class="me-2 language-icon">🇬🇧</span>
|
||||
English
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="ru"
|
||||
aria-pressed="false">
|
||||
<span class="me-2 language-icon">🇷🇺</span>
|
||||
Russian
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-theme"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Toggle theme (auto)">
|
||||
<i class="bi me-2 opacity-50 theme-icon-active"></i>
|
||||
<span class="d-lg-none ms-2"
|
||||
id="bd-theme-text">Toggle theme</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end"
|
||||
aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-theme-value="light"
|
||||
aria-pressed="false">
|
||||
<i class="bi bi-sun-fill me-2 opacity-50 theme-icon"></i>
|
||||
Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-theme-value="dark"
|
||||
aria-pressed="false">
|
||||
<i class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></i>
|
||||
Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center active"
|
||||
data-bs-theme-value="auto"
|
||||
aria-pressed="true">
|
||||
<i class="bi bi-circle-half me-2 opacity-50 theme-icon"></i>
|
||||
Auto
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="pt-5 my-5 text-muted border-top">
|
||||
Created by shmyga · © 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>
|
||||
@@ -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>
|
||||
26
gallery/easel/route/view/common/templates/root_index.html
Normal file
26
gallery/easel/route/view/common/templates/root_index.html
Normal 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 %}
|
||||
15
gallery/easel/route/view/locales/ru/LC_MESSAGES/messages.po
Normal file
15
gallery/easel/route/view/locales/ru/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,15 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Gallery\n"
|
||||
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
|
||||
"Language: ru\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "Weather"
|
||||
msgstr "Погода"
|
||||
|
||||
msgid "TV program"
|
||||
msgstr "Телепрограмма"
|
||||
@@ -1,81 +1,85 @@
|
||||
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.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
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
)
|
||||
templates.env.globals.update({"_": _})
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: Request):
|
||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: Request, 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
|
||||
]
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"channels": channels,
|
||||
"response": responses[0],
|
||||
"responses": responses,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
@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(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: Request, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
@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 = request.app.state.api.schedule
|
||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": results[0],
|
||||
"responses": results,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
|
||||
@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 = request.app.state.api.schedule
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -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%;
|
||||
}
|
||||
@@ -1,57 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
Программа | {{response.channel.name}} | {{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.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 %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<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">
|
||||
<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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value in response.values %}
|
||||
<tr class="{{'live' if value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% 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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value in response.values %}
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% 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>
|
||||
{% for channel in channels %}
|
||||
<a href="schedule/{{channel.id}}"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
<span class="text-primary">{{channel.name}}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,69 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{{'Прямые трансляции' if live else _("TV program")}} | {{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>ТВ</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 %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<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">
|
||||
<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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<table class="{{'live' if live else ''}}">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in responses %}
|
||||
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
|
||||
{% if values|length > 0 %}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="title">{{response.channel.name}}</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for value in values %}
|
||||
<tr class="{{'live' 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% 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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</h4>
|
||||
<div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in responses %}
|
||||
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
|
||||
{% if values|length > 0 %}
|
||||
<tr class="table-primary fs-4">
|
||||
<td colspan="3">
|
||||
<div>{{response.channel.name}}</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for value in values %}
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
34
gallery/easel/route/view/translation.py
Normal file
34
gallery/easel/route/view/translation.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import gettext
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, Header, Request
|
||||
|
||||
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = (
|
||||
ContextVar("translation")
|
||||
)
|
||||
|
||||
|
||||
async def set_language(
|
||||
request: Request,
|
||||
accept_language: str = Header("en"),
|
||||
language: str | None = Cookie(None),
|
||||
):
|
||||
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
|
||||
lang = language or accept_language.split(",")[0].split("-")[0]
|
||||
|
||||
try:
|
||||
t = gettext.translation(
|
||||
"messages", localedir=Path(__file__).parent / "locales", languages=[lang]
|
||||
)
|
||||
except FileNotFoundError:
|
||||
t = gettext.NullTranslations()
|
||||
|
||||
token = _translation.set(t)
|
||||
request.state.language = lang
|
||||
yield lang
|
||||
_translation.reset(token)
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
return _translation.get().gettext(message)
|
||||
@@ -1,88 +1,87 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter, FastAPI
|
||||
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 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.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
base_dir = Path(__file__).parent
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
)
|
||||
templates.env.globals.update({"_": _})
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
def build_weather_response(request: Request, response: WeatherResponse):
|
||||
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)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"locations": locations_data,
|
||||
},
|
||||
)
|
||||
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/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
router = APIRouter()
|
||||
|
||||
@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)
|
||||
@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,
|
||||
},
|
||||
)
|
||||
|
||||
@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
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@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
|
||||
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):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api = request.app.state.api.weather
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -1,37 +1,73 @@
|
||||
<!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>
|
||||
{% 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>
|
||||
</form>
|
||||
<ul id="locations"
|
||||
class="list-group mb-5">
|
||||
{% for location in locations %}
|
||||
<a href="weather/{{location.id}}"
|
||||
class="list-group-item list-group-item-action px-4"
|
||||
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
|
||||
<span class="text-primary">{{location.name}}</span>
|
||||
<span class="small ms-1 text-secondary">
|
||||
{{location.country}}, {{location.district}}, {{location.subdistrict}}
|
||||
</span>
|
||||
<span></span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<script>
|
||||
(function () {
|
||||
document.loadLocations = () => {
|
||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
||||
const container = document.querySelector('#locations');
|
||||
container.innerHTML = '';
|
||||
for (const [id, name] of Object.entries(locations)) {
|
||||
const element = document.createElement('a');
|
||||
element.href = `weather/${id}`;
|
||||
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
|
||||
element.innerHTML = `
|
||||
<span class="text-primary me-auto">${name}</span>
|
||||
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">✕</span>
|
||||
`;
|
||||
container.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
<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 location in locations %}
|
||||
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
document.saveLocation = (location) => {
|
||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
||||
locations[location.id] = location.name;
|
||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
||||
}
|
||||
|
||||
</html>
|
||||
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 %}
|
||||
@@ -1,184 +1,168 @@
|
||||
<!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 %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<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">
|
||||
{% 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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
{% if response.period == 'day' %}
|
||||
<td
|
||||
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
|
||||
<span class="value">{{value.date.strftime('%H:%M')}}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{value.date.strftime('%a %d')}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- cloudness -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Облачность
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness">
|
||||
{% for icon in value.sky | cloudness_icon %}
|
||||
<div class="icon">{{icon}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- temperature -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Температура, °C
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="temperature">
|
||||
{% for temperature in value.temperature %}
|
||||
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
|
||||
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
|
||||
{{temperature}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_direction -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Направление ветра
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind">
|
||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_speed -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Скорость ветра, м/с
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- precipitation -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Осадки, мм
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||
<span class="value">{{value.precipitation or ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- pressure -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Давление, мм рт. ст.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="pressure">
|
||||
{% for pressure in value.pressure %}
|
||||
<div class="value"
|
||||
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
|
||||
{{pressure}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- humidity -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Влажность, %
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="humidity"
|
||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||
<span class="value">{{value.humidity}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% 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>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% for value in response.values %}
|
||||
{% if response.period == 'day' %}
|
||||
<td
|
||||
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
|
||||
<span class="value">{{value.date.strftime('%H:%M')}}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{value.date.strftime('%a %d')}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- cloudness -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Облачность
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness">
|
||||
{% for icon in value.sky | cloudness_icon %}
|
||||
<div class="icon">{{icon}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- temperature -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Температура, °C
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="temperature">
|
||||
{% for temperature in value.temperature %}
|
||||
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
|
||||
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
|
||||
{{temperature}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_direction -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Направление ветра
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind">
|
||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_speed -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Скорость ветра, м/с
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- precipitation -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Осадки, мм
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||
<span class="value">{{value.precipitation or ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- pressure -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Давление, мм рт. ст.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="pressure">
|
||||
{% for pressure in value.pressure %}
|
||||
<div class="value"
|
||||
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
|
||||
{{pressure}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- humidity -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Влажность, %
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="humidity"
|
||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||
<span class="value">{{value.humidity}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,29 @@ 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"],
|
||||
district=item["translations"]["kk"]["district"]["name"],
|
||||
subdistrict=item["translations"]["kk"]["subdistrict"]["name"],
|
||||
)
|
||||
)
|
||||
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
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
0
gallery/painting/openweather/__init__.py
Normal file
0
gallery/painting/openweather/__init__.py
Normal file
67
gallery/painting/openweather/api.py
Normal file
67
gallery/painting/openweather/api.py
Normal 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)),
|
||||
)
|
||||
@@ -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")
|
||||
83
gallery/painting/openweather/openweather.py
Normal file
83
gallery/painting/openweather/openweather.py
Normal 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)
|
||||
52
gallery/painting/openweather/parser.py
Normal file
52
gallery/painting/openweather/parser.py
Normal 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()
|
||||
0
gallery/painting/yandextv/__init__.py
Normal file
0
gallery/painting/yandextv/__init__.py
Normal file
94
gallery/painting/yandextv/api.py
Normal file
94
gallery/painting/yandextv/api.py
Normal 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
|
||||
)
|
||||
@@ -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
28
gallery/sketch/bundle.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="Тест"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="Змиёвка"),
|
||||
]
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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}]}
|
||||
@@ -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}]}
|
||||
@@ -12,6 +12,11 @@ class Model(BaseModel):
|
||||
class Location(Model):
|
||||
id: str
|
||||
name: str
|
||||
lat: float
|
||||
lon: float
|
||||
country: str
|
||||
district: str
|
||||
subdistrict: str
|
||||
|
||||
|
||||
class Cloudness(str, Enum):
|
||||
@@ -25,7 +30,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 +46,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
91
poetry.lock
generated
91
poetry.lock
generated
@@ -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,8 @@ 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 = "beautifulsoup4"
|
||||
@@ -195,6 +202,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 +224,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 +259,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 +269,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 +281,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 +296,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 +309,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 +332,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 +348,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 +369,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 +385,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 +411,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 +429,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 +516,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 +528,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 +550,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 +599,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 +613,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 +624,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 +636,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 +648,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 +663,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 +681,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 +706,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 +776,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 +788,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 +800,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 +900,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 +912,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 +924,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 +936,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 +953,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 +969,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 +992,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 +1094,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 +1109,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 +1134,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 +1155,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 +1174,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 +1189,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 +1204,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 +1219,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 +1231,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 +1292,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 +1308,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 +1397,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 +1416,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 +1428,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 +1440,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 +1452,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 +1464,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 +1482,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 +1494,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 +1512,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 +1524,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 +1537,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 +1555,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 +1568,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 +1581,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 +1619,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 +1627,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 +1715,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 +1797,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 +1896,6 @@ idna = ">=2.0"
|
||||
multidict = ">=4.0"
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "7295e9ec7f7492017c5bbda489026f19bbf155f0ea82402d348b0aa4c03beaca"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "gallery"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = ""
|
||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -38,3 +38,5 @@ gallery = "gallery.main:run"
|
||||
addopts = "-p no:warnings"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.poetry_bumpversion.file."gallery/version.py"]
|
||||
@@ -2,4 +2,4 @@
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
docker build -t shmyga/gallery .
|
||||
docker compose -f docker-compose-develop.yaml up --build --watch
|
||||
45
scripts/docker-action
Executable file
45
scripts/docker-action
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
. .env
|
||||
|
||||
build () {
|
||||
echo "build: $1"
|
||||
. "$1/.env"
|
||||
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||
ARGS=("build")
|
||||
for ARG in ${DOCKER_ARGS[@]}; do
|
||||
ARGS+=("--build-arg" "$ARG")
|
||||
done
|
||||
if [ -n "$PROJECT_TARGET" ]; then
|
||||
ARGS+=("--target" "$PROJECT_TARGET")
|
||||
fi
|
||||
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
|
||||
ARGS+=("-f" "$1/Dockerfile")
|
||||
echo "${ARGS[@]}"
|
||||
docker "${ARGS[@]}"
|
||||
done
|
||||
}
|
||||
|
||||
publish () {
|
||||
echo "publish: $1"
|
||||
. "$1/.env"
|
||||
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:latest
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
|
||||
done
|
||||
}
|
||||
|
||||
DEFAULT_TARGETS="."
|
||||
TARGETS="${@-$DEFAULT_TARGETS}"
|
||||
|
||||
DOCKER_ACTION="${DOCKER_ACTION-build}"
|
||||
|
||||
for TARGET in $TARGETS; do
|
||||
$DOCKER_ACTION "$TARGET"
|
||||
done
|
||||
6
scripts/locales
Executable file
6
scripts/locales
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
cd gallery/easel/route/view/locales/ru/LC_MESSAGES || exit
|
||||
msgfmt messages.po
|
||||
@@ -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
|
||||
@@ -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
19
scripts/version
Executable 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
2
static/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
1
static/.nvmrc
Normal file
1
static/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
1234
static/package-lock.json
generated
Normal file
1234
static/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
static/package.json
Normal file
20
static/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.2.0",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"author": "shmyga <shmyga.z@gmail.com>",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"sass": "^1.99.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
17
static/src/components.ts
Normal file
17
static/src/components.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
class AppLinkElement extends HTMLElement {
|
||||
static observedAttributes = ["icon", "href"];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `
|
||||
<a href="${this.getAttribute("href")}"
|
||||
class="d-flex align-items-center text-body text-decoration-none">
|
||||
<span class="fs-4">
|
||||
<i class="bi bi-${this.getAttribute("icon")}"></i>
|
||||
<span>${this.textContent}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("app-link", AppLinkElement);
|
||||
5
static/src/index.ts
Normal file
5
static/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import "./main.scss";
|
||||
import "bootstrap";
|
||||
import "./theme";
|
||||
import "./language";
|
||||
import "./components";
|
||||
62
static/src/language.ts
Normal file
62
static/src/language.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
(() => {
|
||||
const getStoredLanguage = () => {
|
||||
const m = document.cookie.match(/language=(\w+)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
|
||||
|
||||
const getPreferredLanguage = () => {
|
||||
const storedLanguage = getStoredLanguage();
|
||||
if (storedLanguage) {
|
||||
return storedLanguage;
|
||||
}
|
||||
const result = window.navigator.language.split("-")[0];
|
||||
return ["en", "ru"].includes(result) ? result : "en";
|
||||
};
|
||||
|
||||
const setLanguage = (language: string) => {};
|
||||
|
||||
setLanguage(getPreferredLanguage());
|
||||
|
||||
const showActiveLanguage = (language: string, focus = false) => {
|
||||
const languageSwitcher = document.querySelector("#bd-language");
|
||||
|
||||
if (!languageSwitcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const languageSwitcherText = document.querySelector("#bd-language-text");
|
||||
const activeLanguageIcon = document.querySelector(".language-icon-active");
|
||||
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
|
||||
const activeLanguageIconContent = btnToActive?.querySelector("span")?.textContent;
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
|
||||
element.classList.remove("active");
|
||||
element.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
activeLanguageIcon.textContent = activeLanguageIconContent;
|
||||
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
|
||||
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
|
||||
|
||||
if (focus) {
|
||||
languageSwitcher.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
showActiveLanguage(getPreferredLanguage());
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const language = toggle.getAttribute("data-bs-language-value") || "";
|
||||
setStoredLanguage(language);
|
||||
setLanguage(language);
|
||||
showActiveLanguage(language, true);
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
19
static/src/main.scss
Normal file
19
static/src/main.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
$bootstrap-icons-font-dir: "bootstrap-icons/font/fonts";
|
||||
@import "bootstrap-icons/font/bootstrap-icons";
|
||||
|
||||
@import "./widget.scss";
|
||||
@import "./weather.scss";
|
||||
|
||||
.table.table-compact {
|
||||
td {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-size: contain;
|
||||
}
|
||||
76
static/src/theme.ts
Normal file
76
static/src/theme.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
(() => {
|
||||
const getStoredTheme = () => localStorage.getItem("theme");
|
||||
const setStoredTheme = (theme: string) => localStorage.setItem("theme", theme);
|
||||
|
||||
const getPreferredTheme = () => {
|
||||
const storedTheme = getStoredTheme();
|
||||
if (storedTheme) {
|
||||
return storedTheme;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
if (theme === "auto") {
|
||||
document.documentElement.setAttribute(
|
||||
"data-bs-theme",
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||
);
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
}
|
||||
};
|
||||
|
||||
setTheme(getPreferredTheme());
|
||||
|
||||
const showActiveTheme = (theme: string, focus = false) => {
|
||||
const themeSwitcher = document.querySelector("#bd-theme");
|
||||
|
||||
if (!themeSwitcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeSwitcherText = document.querySelector("#bd-theme-text");
|
||||
const activeThemeIcon = document.querySelector(".theme-icon-active");
|
||||
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
||||
const activeThemeIconClass = btnToActive.querySelector("i.bi").className.match(/bi-[\w-]+/)[0];
|
||||
|
||||
document.querySelectorAll("[data-bs-theme-value]").forEach((element) => {
|
||||
element.classList.remove("active");
|
||||
element.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
const classesToRemove = Array.from(activeThemeIcon.classList).filter((className) => className.startsWith("bi-"));
|
||||
activeThemeIcon.classList.remove(...classesToRemove);
|
||||
activeThemeIcon.classList.add(activeThemeIconClass);
|
||||
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
|
||||
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
|
||||
|
||||
if (focus) {
|
||||
themeSwitcher.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
const storedTheme = getStoredTheme();
|
||||
if (storedTheme !== "light" && storedTheme !== "dark") {
|
||||
setTheme(getPreferredTheme());
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
showActiveTheme(getPreferredTheme());
|
||||
|
||||
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const theme = toggle.getAttribute("data-bs-theme-value") || '';
|
||||
setStoredTheme(theme);
|
||||
setTheme(theme);
|
||||
showActiveTheme(theme, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
73
static/src/weather.scss
Normal file
73
static/src/weather.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
.table-weather {
|
||||
.header {
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
background: rgba(1, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.date.now {
|
||||
background: rgba(0, 128, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.date .value a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudness {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cloudness .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cloudness .icon:first-child {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.temperature .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.temperature .value.positive {
|
||||
color: orangered;
|
||||
}
|
||||
|
||||
.temperature .value.negative {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.wind .direction {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wind .gust {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.precipitation .value {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.pressure {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.pressure .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: blueviolet;
|
||||
}
|
||||
|
||||
.humidity .value {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
17
static/src/widget.scss
Normal file
17
static/src/widget.scss
Normal 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
14
static/vite.config.ts
Normal 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
0
tests/common/__init__.py
Normal file
17
tests/common/mock.py
Normal file
17
tests/common/mock.py
Normal 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)
|
||||
6334
tests/data/gismeteo/10-days.html
Normal file
6334
tests/data/gismeteo/10-days.html
Normal file
File diff suppressed because one or more lines are too long
12
tests/data/gismeteo/__init__.py
Normal file
12
tests/data/gismeteo/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.common.mock import MockSource
|
||||
|
||||
GISMETEO_MOCK_SOURCE = MockSource(
|
||||
Path(__file__).parent,
|
||||
{
|
||||
"today": "today.html",
|
||||
"10-days": "10-days.html",
|
||||
"mq/city/q": "mq_city_q.json",
|
||||
},
|
||||
)
|
||||
400
tests/data/gismeteo/mq_city_q.json
Normal file
400
tests/data/gismeteo/mq_city_q.json
Normal file
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"meta": { "status": true },
|
||||
"data": [
|
||||
{
|
||||
"id": 4432,
|
||||
"kind": "M",
|
||||
"slug": "orel",
|
||||
"coordinates": { "latitude": 52.968498, "longitude": 36.0695 },
|
||||
"obsStationId": 11948,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 156, "slug": "russia", "code": "RU" },
|
||||
"district": { "id": 253, "slug": "oryol-oblast" },
|
||||
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
|
||||
"subdistrict": {
|
||||
"name": "городской округ город Орёл",
|
||||
"nameP": "в городском округе города Орёл",
|
||||
"nameR": "городского округа города Орёл"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
|
||||
"subdistrict": {
|
||||
"name": "городской округ город Орёл",
|
||||
"nameP": "в городском округе города Орёл",
|
||||
"nameR": "городского округа города Орёл"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 13074,
|
||||
"kind": "A",
|
||||
"slug": "orel-yuzhnyy-im-i-s-turgeneva",
|
||||
"coordinates": { "latitude": 52.935001, "longitude": 36.001671 },
|
||||
"obsStationId": 11948,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 156, "slug": "russia", "code": "RU" },
|
||||
"district": { "id": 253, "slug": "oryol-oblast" },
|
||||
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
|
||||
"subdistrict": {
|
||||
"name": "городской округ город Орёл",
|
||||
"nameP": "в городском округе города Орёл",
|
||||
"nameR": "городского округа города Орёл"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
|
||||
"subdistrict": {
|
||||
"name": "городской округ город Орёл",
|
||||
"nameP": "в городском округе города Орёл",
|
||||
"nameR": "городского округа города Орёл"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 112316,
|
||||
"kind": "T",
|
||||
"slug": "orel",
|
||||
"coordinates": { "latitude": 52.0172, "longitude": 30.849199 },
|
||||
"obsStationId": 12921,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 19, "slug": "belarus", "code": "BY" },
|
||||
"district": { "id": 346, "slug": "gomel-region" },
|
||||
"subdistrict": { "id": 1828, "slug": "loyev-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
|
||||
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
|
||||
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
|
||||
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
|
||||
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 178290,
|
||||
"kind": "T",
|
||||
"slug": "orel",
|
||||
"coordinates": { "latitude": 58.799999, "longitude": 34.453701 },
|
||||
"obsStationId": 11657,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 156, "slug": "russia", "code": "RU" },
|
||||
"district": { "id": 248, "slug": "novgorod-oblast" },
|
||||
"subdistrict": { "id": 2857, "slug": "municipal-district-khvoyninsky" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": {
|
||||
"name": "Новгородская область",
|
||||
"nameP": "в Новгородской области",
|
||||
"nameR": "Новгородской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "муниципальный округ Хвойнинский",
|
||||
"nameP": "в муниципальном округе Хвойнинском",
|
||||
"nameR": "муниципального округа Хвойнинского"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": {
|
||||
"name": "Новгородская область",
|
||||
"nameP": "в Новгородской области",
|
||||
"nameR": "Новгородской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "муниципальный округ Хвойнинский",
|
||||
"nameP": "в муниципальном округе Хвойнинском",
|
||||
"nameR": "муниципального округа Хвойнинского"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 112830,
|
||||
"kind": "T",
|
||||
"slug": "orel",
|
||||
"coordinates": { "latitude": 52.182499, "longitude": 30.4349 },
|
||||
"obsStationId": 12920,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 19, "slug": "belarus", "code": "BY" },
|
||||
"district": { "id": 346, "slug": "gomel-region" },
|
||||
"subdistrict": { "id": 1833, "slug": "rechytsa-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
|
||||
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
|
||||
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
|
||||
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
|
||||
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 97816,
|
||||
"kind": "T",
|
||||
"slug": "orilske",
|
||||
"coordinates": { "latitude": 49.088799, "longitude": 36.228401 },
|
||||
"obsStationId": 13147,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
|
||||
"district": { "id": 335, "slug": "kharkiv-oblast" },
|
||||
"subdistrict": { "id": 1646, "slug": "berestyn-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орельское", "nameP": "в Орельском" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Харьковская область",
|
||||
"nameP": "в Харьковской области",
|
||||
"nameR": "Харьковской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Берестинский район",
|
||||
"nameP": "в Берестинском районе",
|
||||
"nameR": "Берестинского района"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орельское", "nameP": "в Орельском" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Харьковская область",
|
||||
"nameP": "в Харьковской области",
|
||||
"nameR": "Харьковской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Берестинский район",
|
||||
"nameP": "в Берестинском районе",
|
||||
"nameR": "Берестинского района"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 97619,
|
||||
"kind": "T",
|
||||
"slug": "orilka",
|
||||
"coordinates": { "latitude": 48.980499, "longitude": 36.0075 },
|
||||
"obsStationId": 13147,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
|
||||
"district": { "id": 335, "slug": "kharkiv-oblast" },
|
||||
"subdistrict": { "id": 1649, "slug": "lozivskyi-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орелька", "nameP": "в Орельке" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Харьковская область",
|
||||
"nameP": "в Харьковской области",
|
||||
"nameR": "Харьковской области"
|
||||
},
|
||||
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орелька", "nameP": "в Орельке" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Харьковская область",
|
||||
"nameP": "в Харьковской области",
|
||||
"nameR": "Харьковской области"
|
||||
},
|
||||
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 78141,
|
||||
"kind": "T",
|
||||
"slug": "orilka",
|
||||
"coordinates": { "latitude": 48.945999, "longitude": 35.689098 },
|
||||
"obsStationId": 13158,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
|
||||
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
|
||||
"subdistrict": { "id": 1184, "slug": "samarivskyi-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орелька", "nameP": "в Орельке" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Днепропетровская область",
|
||||
"nameP": "в Днепропетровской области",
|
||||
"nameR": "Днепропетровской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Самаровский район",
|
||||
"nameP": "в Самаровском районе",
|
||||
"nameR": "Самаровского района"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орелька", "nameP": "в Орельке" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Днепропетровская область",
|
||||
"nameP": "в Днепропетровской области",
|
||||
"nameR": "Днепропетровской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Самаровский район",
|
||||
"nameP": "в Самаровском районе",
|
||||
"nameR": "Самаровского района"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 77735,
|
||||
"kind": "T",
|
||||
"slug": "orilske",
|
||||
"coordinates": { "latitude": 48.587799, "longitude": 34.8111 },
|
||||
"obsStationId": 13158,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
|
||||
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
|
||||
"subdistrict": { "id": 1178, "slug": "dniprovskyi-district" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Днепропетровская область",
|
||||
"nameP": "в Днепропетровской области",
|
||||
"nameR": "Днепропетровской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Днепровский район",
|
||||
"nameP": "в Днепровском районе",
|
||||
"nameR": "Днепровского района"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
|
||||
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
|
||||
"district": {
|
||||
"name": "Днепропетровская область",
|
||||
"nameP": "в Днепропетровской области",
|
||||
"nameR": "Днепропетровской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "Днепровский район",
|
||||
"nameP": "в Днепровском районе",
|
||||
"nameR": "Днепровского района"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
},
|
||||
{
|
||||
"id": 171956,
|
||||
"kind": "T",
|
||||
"slug": "orel",
|
||||
"coordinates": { "latitude": 55.516499, "longitude": 44.0658 },
|
||||
"obsStationId": 11899,
|
||||
"timeZone": 180,
|
||||
"country": { "id": 156, "slug": "russia", "code": "RU" },
|
||||
"district": { "id": 266, "slug": "nizhny-novgorod-oblast" },
|
||||
"subdistrict": { "id": 2796, "slug": "municipal-district-vadsky" },
|
||||
"translations": {
|
||||
"ru": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": {
|
||||
"name": "Нижегородская область",
|
||||
"nameP": "в Нижегородской области",
|
||||
"nameR": "Нижегородской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "муниципальный округ Вадский",
|
||||
"nameP": "в муниципальном округе Вадском",
|
||||
"nameR": "муниципального округа Вадского"
|
||||
}
|
||||
},
|
||||
"kk": {
|
||||
"city": { "name": "Орел", "nameP": "в Орле" },
|
||||
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
|
||||
"district": {
|
||||
"name": "Нижегородская область",
|
||||
"nameP": "в Нижегородской области",
|
||||
"nameR": "Нижегородской области"
|
||||
},
|
||||
"subdistrict": {
|
||||
"name": "муниципальный округ Вадский",
|
||||
"nameP": "в муниципальном округе Вадском",
|
||||
"nameR": "муниципального округа Вадского"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visitCount": 0,
|
||||
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
|
||||
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
|
||||
"redirectUrl": {}
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
6114
tests/data/gismeteo/today.html
Normal file
6114
tests/data/gismeteo/today.html
Normal file
File diff suppressed because one or more lines are too long
10
tests/data/matchtv/__init__.py
Normal file
10
tests/data/matchtv/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.common.mock import MockSource
|
||||
|
||||
MATCHTV_MOCK_SOURCE = MockSource(
|
||||
Path(__file__).parent,
|
||||
{
|
||||
"test": "test.html",
|
||||
},
|
||||
)
|
||||
2
tests/data/matchtv/test.html
Normal file
2
tests/data/matchtv/test.html
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user