Compare commits

..
36 Commits
Author SHA1 Message Date
shmyga b5a8a1536c ci(version): 0.7.0 2026-09-03 20:09:23 +03:00
shmyga 4aafe720dd feat(static): update WeatherLocationElement 2026-09-03 20:01:34 +03:00
shmyga 7c2436fb57 feat(rp5): add rp5 api 2026-09-03 19:47:42 +03:00
shmyga d6487facf4 test(yandexweather): add yandexweather tests 2026-09-01 22:36:54 +03:00
shmyga 86879a3219 ci(version): 0.6.1 2026-08-30 16:04:58 +03:00
shmyga aa0d5a7a41 feat(gismeteo): change gismeteo values merging mode 2026-08-30 16:04:43 +03:00
shmyga bec48e3289 ci(version): 0.6.0 2026-08-28 10:14:35 +03:00
shmyga d8ee66480c build(docker): optimize docker build 2026-08-28 10:13:58 +03:00
shmyga 9f5c43ce92 fix(yandexweather): fix yandexweather parser 2026-08-28 10:13:05 +03:00
shmyga 60c0a7a6fc fix(gismeteo): fix gismeteo parser 2026-08-28 10:02:36 +03:00
shmyga 50c4221b8d feat(yandexweather): add yandexweather api 2026-08-28 10:01:49 +03:00
shmyga a92b63b855 build: update pylint config 2026-08-21 20:10:58 +03:00
shmyga 712c7615a6 test: add default OPENWEATHER_KEY env value for tests 2026-08-21 20:10:27 +03:00
shmyga 1b5d009cdf feat(gismeteo): add gismeteo auth cookie support 2026-08-21 11:05:49 +03:00
shmyga ca08bc282d fix(openweather): fix pressure convertation 2026-08-21 10:28:08 +03:00
shmyga a19de3e01d ci(version): 0.5.4 2026-08-21 10:20:30 +03:00
shmyga f79f754f1b fix(weather): optional pressure and humidity in weather value 2026-08-21 10:19:52 +03:00
shmyga 555b7f4392 ci(version): 0.5.3 2026-08-14 20:59:10 +03:00
shmyga b412a9cee3 fix(static): add autoprefixer 2026-08-14 20:58:33 +03:00
shmyga b0245a1714 build(python): update pyproject.toml format 2026-07-30 15:18:28 +03:00
shmyga 91b530f84c build(docker): add DOCKER_TAG env variable 2026-07-29 11:31:27 +03:00
shmyga 2b1dfad929 build(npm): add babel and postcss webpack rules 2026-07-24 14:00:24 +03:00
shmyga 1df83ec34d ci(version): 0.5.2 2026-07-22 22:16:16 +03:00
shmyga 1c4dac74ca feat(weather): inline weather svg icons 2026-07-22 22:16:16 +03:00
shmyga 75343a21b9 ci(version): 0.5.1 2026-07-22 22:16:16 +03:00
shmyga 83fabdd538 fix(static): fix bootstrap usage 2026-07-22 22:16:16 +03:00
shmyga 29364bb7d4 ci(version): 0.5.0 2026-07-22 22:16:15 +03:00
shmyga 195bd76608 build(scripts): add save docker action 2026-07-22 22:15:08 +03:00
shmyga 436c6c7364 fix(gismeteo): add heavy_rain variant 2026-07-22 22:15:08 +03:00
shmyga 0a734f9c43 feat(static): colorize weather icons 2026-07-22 22:15:08 +03:00
shmyga 6171ec8a26 refactor(static): refactor static module 2026-07-22 22:15:08 +03:00
shmyga 72fd148a54 build(static): fix webpack build warngins 2026-07-22 22:15:06 +03:00
shmyga 12774db70f refactor(static): refactor weather-icons lib 2026-07-22 22:14:41 +03:00
shmyga 0008e99c66 feat(static): use webpack instead of vite 2026-07-22 22:14:41 +03:00
shmyga bad5dff2b2 ci(version): 0.4.2 2026-07-22 21:52:30 +03:00
shmyga e2af31b812 fix(gismeteo): fix empty wind direction value in parser 2026-07-22 21:52:15 +03:00
59 changed files with 4333 additions and 289 deletions
+3 -1
View File
@@ -4,5 +4,7 @@ DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
DOCKER_PROJECTS=("gallery")
DOCKER_TAG="${DOCKER_TAG:-latest}"
OPENWEATHER_KEY="<EMPTY>"
OPENWEATHER_KEY=""
GISMETEO_AUTH=""
+2
View File
@@ -0,0 +1,2 @@
[presets.python.tasks.setup.playwright]
commands = [".venv/bin/playwright install chromium --with-deps --only-shell"]
+4 -15
View File
@@ -4,23 +4,12 @@ ignore=.venv
[MESSAGES CONTROL]
disable=
missing-docstring,
too-few-public-methods,
too-many-instance-attributes,
too-many-arguments,
too-many-locals,
too-many-boolean-expressions,
too-many-public-methods,
no-else-return,
singleton-comparison,
unused-argument,
unspecified-encoding,
fixme,
invalid-name,
duplicate-code,
extension-pkg-allow-list=
lxml,
GeoIP,
pydantic
too-few-public-methods,
too-many-locals,
no-else-return,
[MISCELLANEOUS]
notes=FIXME,TODO
+5 -1
View File
@@ -9,7 +9,7 @@ COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
poetry install --with app --no-root
poetry install --extras app --no-root
COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
@@ -27,8 +27,12 @@ ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
ENV TZ="Europe/Moscow"
COPY --from=builder /app ./
RUN .venv/bin/playwright install chromium --with-deps --only-shell
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/
COPY --from=builder --parents locales/**/*.mo ./
+1
View File
@@ -13,6 +13,7 @@ services:
environment:
- REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
- GISMETEO_AUTH=$GISMETEO_AUTH
- DEBUG=1
ports:
- 8000:80
+5 -4
View File
@@ -1,18 +1,19 @@
name: gallery
name: gallery-${DOCKER_TAG}
services:
redis:
container_name: gallery-redis
container_name: gallery-${DOCKER_TAG}-redis
image: redis:alpine
stop_grace_period: 3s
volumes:
- redis_data:/data
app:
container_name: gallery-app
image: ${DOCKER_ROOT}/gallery
container_name: gallery-${DOCKER_TAG}-app
image: ${DOCKER_ROOT}/gallery:${DOCKER_TAG}
environment:
- REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
- GISMETEO_AUTH=$GISMETEO_AUTH
depends_on:
- redis
ports:
+1
View File
@@ -9,6 +9,7 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.terminal.useEnvFile": true,
"python.analysis.importFormat": "relative",
"python-envs.pythonProjects": [
{
"path": ".",
+1 -1
View File
@@ -10,7 +10,7 @@ caches.set_config(
{
"default": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.StringSerializer"},
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
},
"redis": {
"cache": "aiocache.RedisCache",
@@ -24,7 +24,7 @@ def is_widget(request: Request) -> bool:
def base_context_processor(request: Request) -> dict:
return {
"is_widget": is_widget(request),
"provider": request.query_params.get("provider"),
"provider": request.query_params.get("provider") or request.path_params.get("provider"),
}
@@ -19,6 +19,7 @@ locale=request.state.language)}}
href="../../..">
<i class="bi bi-arrow-up-square"></i>
</a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
@@ -17,6 +17,7 @@
href="../tag/days-10">
<i class="bi bi-arrow-up-square"></i>
</a>
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
@@ -24,6 +25,7 @@
</a>
{% endif %}
{% if response.period == 'days' %}
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
locale=request.state.language)}}</span>
@@ -120,7 +122,7 @@
<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 %}
{% if value.wind_gust != value.wind_speed and value.wind_gust > 0 %}
<span class="gust">
({{value.wind_gust}})
</span>
@@ -172,7 +174,7 @@
{% 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>
{% if value.humidity > -1%}<span class="value">{{value.humidity}}</span>{% endif %}
</td>
{% endfor %}
</tr>
+4
View File
@@ -7,7 +7,9 @@ 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.rp5.api import RP5WeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.painting.yandexweather.api import YandexWeatherApi
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi
@@ -18,6 +20,8 @@ api = ApiBundle(
CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
CachedWeatherApi(YandexWeatherApi()),
CachedWeatherApi(RP5WeatherApi()),
]
)
app = build_app(api)
+17 -18
View File
@@ -1,6 +1,7 @@
import datetime
import json
import logging
from os import environ
from typing import Any
from bs4 import BeautifulSoup
@@ -9,6 +10,7 @@ from fastapi import HTTPException, status
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -18,21 +20,12 @@ logger = logging.getLogger("gismeteo")
class GismeteoApi(WeatherApi):
PROVIDER = "gismeteo"
SOURCE = ApiSource(
"https://www.gismeteo.ru",
cookies={
"cf_clearance": (
"zPj8qeaYZWp5BUZsWIRD7kd32s59IDWWaVvD7GGf6b4-1724254262-1.2.1.1-"
"R1PaHoqlm4OEgHfrOg6oAJmVmFJ7053JI_QDjIFlCAmbNY0oyw1J4QtgmEkAkTWF4C99X5hpXmZB2en."
"9Ey3NL0kzaE10UGBHPli5OLxHNKC3wQ34xmxTxg6GcKouXOPRgqo92lVRpQP7ghvDmvvT_"
"S6YXDbRonkamptWhZiAsIhsnIQg2Mt7Lk1fxSEVbaey_RXVQBvaR2ofiFgIavcbQPRFSvJ."
"Ct_FujmymTAZWh1XHTptK32kur81NXyPuARThetOKQm8AklaMa855Oom6Bms1a4F9b0yEHSd2inA4j3FR7uauu8hhwO0Z2goa3."
"ktpXXAQLz6dm.fBEWIHD7wooda15X57tCcDOely4hxhO4LK2lIH6TbOr."
"rZQAiRRhltHiCizP1Ou2SiF_81lb1umoDh739tQD1cnw_USnL."
"JwTHvkB00ZnaHRMi4FCBrsKIE0NxJO6kiv1QANqumzf5.8_pSIGRo2cKeO8xCydduDNFbStdOmLR6FU7ZKqVu"
)
},
)
def _build_source(self) -> ApiSource:
return ApiSource(
"https://www.gismeteo.ru",
cookies={"xauth": auth} if (auth := environ.get("GISMETEO_AUTH")) else None,
)
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: list[dict[str, Any]] = []
@@ -45,6 +38,12 @@ class GismeteoApi(WeatherApi):
result.append({})
result[index][parser.KEY] = value
values = [WeatherValue(**item) for item in result]
# group values by 3 hours
if len(values) > 8:
n = 3
values = [
merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n)
]
return WeatherResponse(
location=location or "n/a",
date=date,
@@ -75,7 +74,7 @@ class GismeteoApi(WeatherApi):
latitude = 52.968498
longitude = 36.0695
data = json.loads(
await self.SOURCE.request(
await self.source.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
)
@@ -108,9 +107,9 @@ class GismeteoApi(WeatherApi):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
)
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
data = await self.source.request(f"weather-{location_id}/{datehelp.dump(date)}")
return self._parse_oneday(date, data)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
data = await self.source.request(f"weather-{location_id}/{days}-days")
return self._parse_manydays(data)
+18 -25
View File
@@ -11,8 +11,8 @@ from gallery.sketch.weather.model import (
Precipitation,
Sky,
WindDirection,
WindDirectionDeg,
)
from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from .core import BaseWidgetParser, RowParser
@@ -46,9 +46,15 @@ class DateParser(RowParser[datetime.datetime]):
time = datetime.datetime.fromtimestamp(timestamp)
yield time
else:
month = None
for item in tag.select(".widget-row.widget-row-date > .row-item"):
date_str = item.text
date = dateparser.parse(date_str, languages=["ru"])
if date is None:
raise ParseError.invalid_date(date_str)
if month and month > date.month:
date = date.replace(month=month)
month = date.month
yield date
@@ -65,6 +71,7 @@ class SkyParser(RowParser[Sky]):
PRECIPITATION_MAP: dict[str, Precipitation] = {
"без осадков": Precipitation.NO,
"без существенных осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN,
"сильный дождь": Precipitation.HEAVY_RAIN,
"очень сильный дождь": Precipitation.HEAVY_RAIN,
@@ -82,8 +89,13 @@ class SkyParser(RowParser[Sky]):
"град": Precipitation.HAIL,
}
THUNDER = "гроза"
FOG = "дымка"
THUNDER = {"гроза"}
FOG = {"дымка", "туман"}
@classmethod
def _detect_flag(cls, values: set[str], flag_values: set[str]) -> tuple[set[str], bool]:
result_values = values - flag_values
return result_values, len(result_values) < len(values)
def parse_row(self, tag: Tag) -> Iterable[Sky]:
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
@@ -91,14 +103,8 @@ class SkyParser(RowParser[Sky]):
values = {item.strip().lower() for item in sky_str.split(",")}
cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO
thunder = False
fog = False
if self.THUNDER in values:
thunder = True
values.remove(self.THUNDER)
if self.FOG in values:
fog = True
values.remove(self.FOG)
values, thunder = self._detect_flag(values, self.THUNDER)
values, fog = self._detect_flag(values, self.FOG)
for k, v in self.CLOUDNESS_MAP.items():
if k in values:
cloudness = v
@@ -147,23 +153,10 @@ class WindGustParser(RowParser[int]):
class WindDirectionParser(RowParser[WindDirection]):
KEY = "wind_direction"
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"—": WindDirection.CALM,
"штиль": WindDirection.CALM,
"с": WindDirection.N,
"св": WindDirection.NE,
"в": WindDirection.E,
"юв": WindDirection.SE,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
wind_direction_str = item.text.lower().strip()
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
yield parse_wind_direction(wind_direction_str)
class PrecipitationParser(RowParser[float]):
+5 -3
View File
@@ -11,11 +11,13 @@ logger = logging.getLogger("matchtv")
class MatchTvApi(ScheduleApi):
PROVIDER = "matchtv"
SOURCE = ApiSource("https://matchtv.ru")
def _build_source(self) -> ApiSource:
return ApiSource("https://matchtv.ru")
async def find_channels(self, query: str) -> list[Channel]:
endpoint = "api/v1/channels"
data = json.loads(await self.SOURCE.request(endpoint))
data = json.loads(await self.source.request(endpoint))
result = []
query = query.lower()
for item in data["result"]:
@@ -27,7 +29,7 @@ class MatchTvApi(ScheduleApi):
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
data = json.loads(await self.SOURCE.request(endpoint))
data = json.loads(await self.source.request(endpoint))
channel_data = data["result"]["channels"][0]
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
values = []
+8 -6
View File
@@ -18,9 +18,11 @@ from .parser import FORECAST_ITEM_PARSER
logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi):
class OpenWeatherApi(WeatherApi[OpenWeather]):
PROVIDER = "openweather"
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
def _build_source(self) -> OpenWeather:
return OpenWeather(environ["OPENWEATHER_KEY"])
@classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]:
@@ -32,18 +34,18 @@ class OpenWeatherApi(WeatherApi):
ttl=TimeUnit.DAY,
)
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
return await self.SOURCE.get_location(*self._parse_location(location_id))
return await self.source.get_location(*self._parse_location(location_id))
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.DAY,
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> Forecast:
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
return await self.source.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]:
result = await self.SOURCE.find_locations(query)
result = await self.source.find_locations(query)
return [
Location(
id=f"{item.lat}:{item.lon}",
+5 -5
View File
@@ -76,28 +76,28 @@ class Location(Model):
local_names: dict[str, str] | None = None
class OpenWeather:
class OpenWeather(ApiSource):
BASE_URL = "https://api.openweathermap.org"
def __init__(self, api_key: str):
self._api_key = api_key
self._source = ApiSource(self.BASE_URL)
super().__init__(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 = await self.request(endpoint)
response_data = json.loads(response)
return Forecast.model_validate(response_data)
async def find_locations(self, query: str, limit: int = 5) -> list[Location]:
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
response = await self._source.request(endpoint)
response = await self.request(endpoint)
response_data = json.loads(response)
return [Location.model_validate(item) for item in response_data]
async def get_location(self, lat: float, lon: float) -> Location:
limit = 1
endpoint = f"geo/1.0/reverse?lat={lat}&lon={lon}&limit={limit}&appid={self._api_key}"
response = await self._source.request(endpoint)
response = await self.request(endpoint)
response_data = json.loads(response)
return Location.model_validate(response_data[0])
+1 -1
View File
@@ -29,7 +29,7 @@ class ForecastItemParser:
# 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.pressure = [round(item.main.pressure * 0.75006)]
value.humidity = item.main.humidity
value.wind_speed = round(item.wind.speed)
value.wind_gust = round(item.wind.gust)
View File
+103
View File
@@ -0,0 +1,103 @@
import datetime
import logging
import aiohttp
from aiocache import cached
from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import (
Location,
WeatherResponse,
)
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from .parser import parse_page
logger = logging.getLogger("rp5")
HEADERS: dict[str, str] = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
"Connection": "keep-alive",
"Host": "rp5.ru",
"Referer": "https://rp5.ru/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": ApiSource.DEFAULT_USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
}
class RP5WeatherApi(WeatherApi):
PROVIDER = "rp5"
def _build_source(self) -> ApiSource:
return ApiSource(
"https://rp5.ru",
headers=HEADERS,
)
async def find_locations(self, query: str) -> list[Location]:
endpoint = "docs/search/ru"
data = aiohttp.FormData({"searchStr": query})
response = await self.source.request(endpoint, method="POST", data=data)
soup = BeautifulSoup(response, features="html.parser")
table = soup.select_one(".searchResults")
result = []
for row in table.select("tr")[1:]:
cells = row.select("td")
name = cells[0].text.strip()
location_id = cells[0].select_one("a").attrs["href"].lstrip("/")
country = cells[1].text.strip()
district = cells[2].text.strip()
subdistrict = cells[3].text.strip()
result.append(
Location(
id=location_id,
name=name,
provider=self.provider,
lat=0,
lon=0,
country=country,
country_code="ru", # TODO: contry_code from country
district=district,
subdistrict=subdistrict,
)
)
return result
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
endpoint = f"{location_id}"
content = await self.source.request(endpoint)
soup = BeautifulSoup(content, features="html.parser")
return parse_page(soup)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
for response in forecast:
if response.date == date:
return response
raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
values = [merge_weather_values(item.date, item.values) for item in forecast]
return WeatherResponse(
location=forecast[0].location,
date=datetime.date.today(),
period="days",
values=values,
)
+108
View File
@@ -0,0 +1,108 @@
import re
import dateparser
from bs4 import BeautifulSoup, Tag
from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
WeatherResponse,
WeatherValue,
)
from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from gallery.sketch.weather.util import build_weather_value
CLOUDNESS_MAP = {
"Ясно": Cloudness.CLEAR,
"Малооблачно": Cloudness.PARTLY_CLOUDY,
"Облачно с прояснениями": Cloudness.PARTLY_CLOUDY,
"Переменная облачность": Cloudness.PARTLY_CLOUDY,
"Небольшая облачность": Cloudness.CLOUDY,
"Облачно": Cloudness.CLOUDY,
"Значительная облачность": Cloudness.MAINLY_CLOUDY,
"Пасмурная погода": Cloudness.MAINLY_CLOUDY,
}
PRECIPITATION_MAP = {
"Явления погоды отсутствуют": Precipitation.NO,
"Cлабый дождь": Precipitation.SMALL_RAIN,
}
def _parse_wind_speed(cell: Tag) -> int:
element = cell.select_one(".wv_0")
if element:
return int(element.text.strip() or "0")
return 0
# pylint:disable=too-many-branches
def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip()
table = page.select_one("#forecastTable")
if table is None:
raise ParseError("#forecastTable not found")
(
date_row,
time_row,
cloudness_row,
precipitation_row,
temperature_row,
_,
pressure_row,
wind_speed_row,
wind_gust_row,
wind_direction_row,
humidity_row,
*_,
) = table.select("tr")
result: list[WeatherResponse] = []
values: list[WeatherValue] = []
for index, cell in enumerate(date_row.select("td")):
colspan = int(cell.attrs["colspan"])
if index == 0:
colspan -= 1
date_str = ",".join(cell.text.strip().split(",")[-2:])
date = dateparser.parse(date_str, languages=["ru"])
if date is None:
raise ParseError.invalid_date(date_str)
item_values = [build_weather_value(date) for _ in range(colspan)]
result.append(
WeatherResponse(
location=location,
date=date.date(),
period="day",
values=item_values,
)
)
values += item_values
for index, cell in enumerate(time_row.select("td")[1:]):
hour = int(cell.text.strip())
values[index].date = values[index].date.replace(hour=hour)
for index, cell in enumerate(cloudness_row.select("td")[1:]):
onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"]
cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip()
values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str]
for index, cell in enumerate(precipitation_row.select("td")[1:]):
onmouseover = cell.select_one(".pr_0").attrs["onmouseover"]
precipitation_str = onmouseover.split(",")[1].strip(" '")
precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str)
if precipitation_value_matched:
# one hour value
values[index].precipitation = float(precipitation_value_matched.group(1)) * 6
if "(" in precipitation_str:
precipitation_str = precipitation_str.split(" (")[0]
values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str]
for index, cell in enumerate(temperature_row.select("td")[1:]):
values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())]
for index, cell in enumerate(pressure_row.select("td")[1:]):
values[index].pressure = [int(cell.select_one(".p_0").text.strip())]
for index, cell in enumerate(wind_speed_row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell)
for index, cell in enumerate(wind_gust_row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell)
for index, cell in enumerate(wind_direction_row.select("td")[1:]):
values[index].wind_direction = parse_wind_direction(cell.text.strip())
for index, cell in enumerate(humidity_row.select("td")[1:]):
values[index].humidity = int(cell.text.strip())
return result
+5 -3
View File
@@ -43,14 +43,16 @@ HEADERS: dict[str, str] = {
class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
def _build_source(self) -> ApiSource:
return ApiSource("https://tv.yandex.ru", headers=HEADERS)
async def find_channels(self, query: str) -> list[Channel]:
url = (
"https://suggest-multi.yandex.ru/suggest-tv2?"
f"v=4&uil=ru&lr=10&count_channels=4&count_programs=4&sn=50&part={query}"
)
_, values = json.loads(await self.SOURCE.request(url))
_, values = json.loads(await self.source.request(url))
result = []
for _, name, content in values:
if content["label"] == "Каналы":
@@ -60,7 +62,7 @@ class YandexTvApi(ScheduleApi):
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
endpoint = f"channels/{channel_id}?date={date:%Y-%m-%d}"
data = await self.SOURCE.request(endpoint)
data = await self.source.request(endpoint)
soup = BeautifulSoup(data, features="html.parser")
if soup.select_one(".CheckboxCaptcha") is not None:
raise RuntimeError("Captcha")
+75
View File
@@ -0,0 +1,75 @@
import datetime
import json
import logging
from aiocache import cached
from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import (
Location,
WeatherResponse,
)
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from .parser import parse_article
logger = logging.getLogger("yandexweather")
class YandexWeatherApi(WeatherApi):
PROVIDER = "yandex"
def _build_source(self) -> ApiSource:
return ApiSource("https://yandex.ru")
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
endpoint = f"pogoda/ru/{location_id}/details"
content = await self.source.request(endpoint, mode="browser")
soup = BeautifulSoup(content, features="html.parser")
location = soup.select_one("[class^=Suggest_placeholder]").text.strip()
articles = soup.select("article[class^=AppForecastDay]")
return [parse_article(article, location) for article in articles]
async def find_locations(self, query: str) -> list[Location]:
endpoint = f"weather/api/suggest?part={query}&lang=ru&type=weather"
response = await self.source.request(endpoint)
data = json.loads(response)
return [
Location(
id=str(item["geoId"]),
name=item["title"],
provider=self.provider,
lat=item["coords"]["lat"],
lon=item["coords"]["lon"],
country="ru",
country_code="ru",
district=item["description"],
subdistrict="",
)
for item in data
]
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
for response in forecast:
if response.date == date:
return response
raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
values = [merge_weather_values(item.date, item.values) for item in forecast]
return WeatherResponse(
location=forecast[0].location,
date=datetime.date.today(),
period="days",
values=values,
)
+91
View File
@@ -0,0 +1,91 @@
import logging
from enum import Enum
import dateparser
from bs4 import Tag
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WeatherResponse
from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from gallery.sketch.weather.util import build_weather_value
logger = logging.getLogger("yandexweather")
class DayPart(Enum):
MORNING = ("Утром", 6)
AFTERNOON = ("Днём", 12)
EVENING = ("Вечером", 18)
NIGHT = ("Ночью", 0)
def __init__(self, title: str, hour: int):
super().__init__()
self.title = title
self.hour = hour
def parse_sky(sky: str) -> Sky:
cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO
thunder = False
fog = False
if sky == "ясно":
pass
elif sky == "облачно с прояснениями":
cloudness = Cloudness.PARTLY_CLOUDY
elif sky == "малооблачно":
cloudness = Cloudness.CLOUDY
elif sky == "пасмурно":
cloudness = Cloudness.MAINLY_CLOUDY
elif sky == "небольшой дождь":
precipitation = Precipitation.SMALL_RAIN
elif sky == "дождь":
precipitation = Precipitation.RAIN
elif sky == "ливень":
precipitation = Precipitation.SHOWER
else:
logger.warning("unknown sky value: %s", sky)
return Sky(
cloudness=cloudness,
precipitation=precipitation,
thunder=thunder,
fog=fog,
)
def parse_article(article: Tag, location: str) -> WeatherResponse:
date_str = article.select_one("[class^=AppForecastDayHeader_dayTitle]").text.split(",")[-1].strip()
date = dateparser.parse(date_str, languages=["ru"])
if date is None:
raise ParseError.invalid_date(date_str)
values = []
for day_part in DayPart:
value = build_weather_value(date.replace(hour=day_part.hour))
day_part_tag = article.find(string=day_part.title)
temperature_tag = day_part_tag.next_element
temperature = int(temperature_tag.text.strip().rstrip("°"))
value.temperature.append(temperature)
sky_tag = temperature_tag.next_element.next_element.next_element
sky_str = sky_tag.text.strip()
value.sky = parse_sky(sky_str)
temperature_tag = sky_tag.next_element.next_element
temperature = int(temperature_tag.text.strip().rstrip("°"))
value.temperature.append(temperature)
wind_speed_tag = temperature_tag.next_element.next_element
wind_speed = int(wind_speed_tag.text.strip())
value.wind_speed = wind_speed
wind_direction_tag = wind_speed_tag.next_element.next_element.next_element.next_element
wind_direction_str = wind_direction_tag.text.strip()
value.wind_direction = parse_wind_direction(wind_direction_str)
humidity_tag = wind_direction_tag.next_element.next_element.next_element.next_element
humidity = int(humidity_tag.text.strip().rstrip("%"))
value.humidity = humidity
pressure_tag = humidity_tag.next_element.next_element
pressure = int(pressure_tag.text.strip())
value.pressure.append(pressure)
values.append(value)
return WeatherResponse(
location=location,
date=date,
period="day",
values=values,
)
+16 -1
View File
@@ -1,10 +1,19 @@
from typing import TypeVar
from .source import ApiSource
class Api:
S = TypeVar("S", bound=ApiSource)
class Api[S = ApiSource]:
TYPE: str
PROVIDER: str
_source: S | None = None
def _build_source(self) -> S:
raise NotImplementedError
@property
def type(self) -> str:
return self.TYPE
@@ -13,5 +22,11 @@ class Api:
def provider(self) -> str:
return self.PROVIDER
@property
def source(self) -> S:
if self._source is None:
self._source = self._build_source()
return self._source
API = TypeVar("API", bound=Api)
+4
View File
@@ -1,5 +1,6 @@
from typing import Generic, NamedTuple
from gallery.sketch.source import ApiSource
from gallery.util import TimeUnit
from .api import API, Api
@@ -19,6 +20,9 @@ class CachedApi(Api, Generic[API]):
def __init__(self, api: API):
self._api = api
def _build_source(self) -> ApiSource:
raise RuntimeError("Source not used in CachedApi")
@property
def provider(self) -> str:
return self._api.provider
+2 -1
View File
@@ -1,10 +1,11 @@
import datetime
from ..api import Api
from ..source import ApiSource
from .model import Channel, Schedule
class ScheduleApi(Api):
class ScheduleApi[S = ApiSource](Api[S]):
TYPE = "schedule"
async def find_channels(self, query: str) -> list[Channel]:
+45 -7
View File
@@ -1,9 +1,17 @@
import datetime
import logging
from typing import Any, Literal
import aiohttp
from playwright.async_api import async_playwright
logger = logging.getLogger("source")
RequestMode = Literal["base", "browser"]
DEFAULT_REQUEST_MODE: RequestMode = "base"
DEFAULT_METHOD = "GET"
class ApiSource:
DEFAULT_USER_AGENT = (
@@ -25,17 +33,47 @@ class ApiSource:
self._cookies = cookies
self._headers = headers
async def request(self, endpoint: str) -> str:
if endpoint.startswith("https:"):
url = endpoint
else:
url = f"{self._base_url}/{endpoint}"
logger.info(url)
async def _base_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
async with aiohttp.ClientSession(
headers=headers,
cookies=self._cookies,
raise_for_status=True,
) as session:
async with session.request("GET", url, timeout=self._timeout) as response:
async with session.request(method, url, timeout=self._timeout, data=data) as response:
return await response.text()
async def _browser_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(url, timeout=datetime.timedelta(seconds=self._timeout), wait_until="networkidle")
content = await page.content()
await browser.close()
return content
async def request(
self,
endpoint: str,
/,
mode: RequestMode = DEFAULT_REQUEST_MODE,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
if endpoint.startswith("https:"):
url = endpoint
else:
url = f"{self._base_url}/{endpoint}"
logger.info(url)
fun = getattr(self, f"_{mode}_request")
return await fun(url, method, data)
+2 -1
View File
@@ -1,10 +1,11 @@
import datetime
from ..api import Api
from ..source import ApiSource
from .model import Location, WeatherResponse
class WeatherApi(Api):
class WeatherApi[S = ApiSource](Api[S]):
TYPE = "weather"
async def find_locations(self, query: str) -> list[Location]:
+13 -3
View File
@@ -2,7 +2,7 @@ import datetime
from enum import StrEnum, auto
from typing import Self
from pydantic import BaseModel
from pydantic import BaseModel, Field
class Model(BaseModel):
@@ -28,6 +28,11 @@ class Cloudness(StrEnum):
CLOUDY = auto()
MAINLY_CLOUDY = auto()
values: list[Self]
Cloudness.values = list(Cloudness)
class Precipitation(StrEnum):
NO = auto()
@@ -39,6 +44,11 @@ class Precipitation(StrEnum):
HEAVY_SNOW = auto()
HAIL = auto()
values: list[Self]
Precipitation.values = list(Precipitation)
class Sky(Model):
cloudness: Cloudness
@@ -116,8 +126,8 @@ class WeatherValue(Model):
wind_gust: int
wind_direction: float
precipitation: float
pressure: list[int]
humidity: int
pressure: list[int] = Field(default=[])
humidity: int = Field(default=-1)
class WeatherResponse(Model):
+29
View File
@@ -0,0 +1,29 @@
from typing import Self
from gallery.sketch.weather.model import WindDirection, WindDirectionDeg
class ParseError(Exception):
@classmethod
def invalid_date(cls, date_str: str) -> Self:
return cls("Invalid date string", date_str)
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"—": WindDirection.CALM,
"": WindDirection.CALM,
"штиль": WindDirection.CALM,
"штл": WindDirection.CALM,
"с": WindDirection.N,
"св": WindDirection.NE,
"в": WindDirection.E,
"юв": WindDirection.SE,
"ю": WindDirection.S,
"юз": WindDirection.SW,
"з": WindDirection.W,
"сз": WindDirection.NW,
}
def parse_wind_direction(value: str) -> WindDirectionDeg:
return WindDirectionDeg.from_direction(WIND_DIRECTION_MAP[value.replace("-", "").lower()])
+23 -8
View File
@@ -1,8 +1,11 @@
import datetime
import statistics
from typing import Literal
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
MergeMode = Literal["interval", "average"]
def build_weather_value(date: datetime.datetime) -> WeatherValue:
return WeatherValue(
@@ -19,11 +22,25 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
wind_direction=WindDirectionDeg(-1),
precipitation=0,
pressure=[],
humidity=0,
humidity=-1,
)
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
def _merge_values(values: list[int], mode: MergeMode) -> list[int]:
if mode == "interval":
if (max_value := max(values)) == (min_value := min(values)):
return [max_value]
else:
return [max_value, min_value]
else:
return [round(sum(values) / len(values))]
def merge_weather_values(
date: datetime.datetime,
values: list[WeatherValue],
mode: MergeMode = "interval",
) -> WeatherValue:
result = build_weather_value(date)
temperatures = []
pressures = []
@@ -44,19 +61,17 @@ def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) ->
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.temperature = _merge_values(temperatures, mode)
result.pressure = _merge_values(pressures, mode)
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:
if Cloudness.values.index(item) > Cloudness.values.index(result.sky.cloudness):
result.sky.cloudness = item
# TODO: merge precipitations
for item in precipitations:
if item != Precipitation.NO:
if Precipitation.values.index(item) > Precipitation.values.index(result.sky.precipitation):
result.sky.precipitation = item
result.precipitation = precipitation
return result
+1 -1
View File
@@ -1,4 +1,4 @@
import tomllib
from pathlib import Path
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["project"]["version"]
Generated
+258 -85
View File
@@ -192,9 +192,10 @@ frozenlist = ">=1.1.0"
name = "annotated-doc"
version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
@@ -206,7 +207,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main", "app"]
groups = ["main"]
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"},
@@ -216,9 +217,10 @@ files = [
name = "anyio"
version = "4.14.1"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"},
{file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"},
@@ -258,9 +260,10 @@ files = [
name = "babel"
version = "2.18.0"
description = "Internationalization utilities"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"},
{file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"},
@@ -347,9 +350,10 @@ uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ;
name = "certifi"
version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
optional = true
python-versions = ">=3.7"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
@@ -361,11 +365,12 @@ version = "8.4.2"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["app", "dev"]
groups = ["main", "dev"]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
]
markers = {main = "extra == \"app\""}
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
@@ -376,12 +381,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"]
groups = ["main", "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\""}
markers = {main = "extra == \"app\" and (platform_system == \"Windows\" or sys_platform == \"win32\")", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
[[package]]
name = "dateparser"
@@ -409,9 +414,10 @@ langdetect = ["langdetect (>=1.0.0)"]
name = "detect-installer"
version = "0.1.0"
description = "Detect how a Python package was installed and get the correct upgrade command"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a"},
{file = "detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7"},
@@ -437,9 +443,10 @@ profile = ["gprof2dot (>=2022.7.29)"]
name = "dnspython"
version = "2.8.0"
description = "DNS toolkit"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
{file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
@@ -458,9 +465,10 @@ wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
name = "email-validator"
version = "2.3.0"
description = "A robust email address syntax and deliverability validation library."
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
{file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
@@ -474,9 +482,10 @@ idna = ">=2.0.0"
name = "fastapi"
version = "0.139.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189"},
{file = "fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145"},
@@ -507,9 +516,10 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand
name = "fastapi-cli"
version = "0.0.27"
description = "Run and manage FastAPI apps from the command line with FastAPI CLI. 🚀"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "fastapi_cli-0.0.27-py3-none-any.whl", hash = "sha256:2e389a40f318e29fec8cb1e289f267f17c048876fb82dbfa869a10b16740495d"},
{file = "fastapi_cli-0.0.27.tar.gz", hash = "sha256:1dffb1e40c0c88f2e0171a8a252a2b615c1e63ff8c05626649e4badd6a84336a"},
@@ -530,9 +540,10 @@ standard-no-fastapi-cloud-cli = ["uvicorn[standard] (>=0.15.0)"]
name = "fastapi-cloud-cli"
version = "0.22.0"
description = "Deploy and manage FastAPI Cloud apps from the command line 🚀"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "fastapi_cloud_cli-0.22.0-py3-none-any.whl", hash = "sha256:799e263140d1b6e679390fc6e90da404556aee6730fd479d3b52475e850073bf"},
{file = "fastapi_cloud_cli-0.22.0.tar.gz", hash = "sha256:cde14470227fc9275075ff537977d2991e8ce1baf947055d7a4149726de27e08"},
@@ -556,9 +567,10 @@ standard = ["uvicorn[standard] (>=0.15.0)"]
name = "fastar"
version = "0.11.0"
description = "High-level bindings for the Rust tar crate"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "fastar-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7c906ad371ca365591ebcb7630009923f3eceb20956814494d15591a78e9e46"},
{file = "fastar-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6919497b35fa5bd978d2c26ee117cf1771b90ee5073f7518e44b9bc364b57715"},
@@ -841,13 +853,107 @@ files = [
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
]
[[package]]
name = "greenlet"
version = "3.5.5"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56"},
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638"},
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0"},
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4"},
{file = "greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc"},
{file = "greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864"},
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f"},
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db"},
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39"},
{file = "greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53"},
{file = "greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5"},
{file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d"},
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759"},
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b"},
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2"},
{file = "greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18"},
{file = "greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52"},
{file = "greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0"},
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b"},
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537"},
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e"},
{file = "greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd"},
{file = "greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc"},
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -857,9 +963,10 @@ files = [
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -879,9 +986,10 @@ trio = ["trio (>=0.22.0,<1.0)"]
name = "httptools"
version = "0.8.0"
description = "A collection of framework independent HTTP protocol utils."
optional = false
optional = true
python-versions = ">=3.9"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826"},
{file = "httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77"},
@@ -939,9 +1047,10 @@ files = [
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -966,7 +1075,7 @@ version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main", "app"]
groups = ["main"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@@ -1006,9 +1115,10 @@ colors = ["colorama"]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
optional = true
python-versions = ">=3.7"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@@ -1024,9 +1134,10 @@ i18n = ["Babel (>=2.7)"]
name = "markdown-it-py"
version = "4.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
@@ -1048,9 +1159,10 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-tim
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
optional = true
python-versions = ">=3.9"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@@ -1159,9 +1271,10 @@ files = [
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
optional = true
python-versions = ">=3.7"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -1376,6 +1489,28 @@ files = [
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
]
[[package]]
name = "playwright"
version = "1.62.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f"},
{file = "playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034"},
{file = "playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da"},
{file = "playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3"},
{file = "playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff"},
{file = "playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c"},
{file = "playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1"},
{file = "playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1"},
]
[package.dependencies]
greenlet = ">=3.1.1,<4.0.0"
pyee = ">=13,<14"
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1529,7 +1664,7 @@ version = "2.13.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main", "app"]
groups = ["main"]
files = [
{file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
{file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
@@ -1552,7 +1687,7 @@ version = "2.46.4"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main", "app"]
groups = ["main"]
files = [
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
@@ -1683,9 +1818,10 @@ typing-extensions = ">=4.14.1"
name = "pydantic-extra-types"
version = "2.11.1"
description = "Extra Pydantic types."
optional = false
optional = true
python-versions = ">=3.9"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1"},
{file = "pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049"},
@@ -1709,9 +1845,10 @@ uuid-utils = ["uuid-utils (>=0.6.0) ; python_version < \"3.14\""]
name = "pydantic-settings"
version = "2.14.2"
description = "Settings management using Pydantic"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"},
{file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"},
@@ -1729,17 +1866,36 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pyee"
version = "13.0.1"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["app", "test"]
groups = ["main", "test"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
markers = {main = "extra == \"app\""}
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
@@ -1829,9 +1985,10 @@ six = ">=1.5"
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
@@ -1844,9 +2001,10 @@ cli = ["click (>=5.0)"]
name = "python-multipart"
version = "0.0.32"
description = "A streaming multipart parser for Python"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"},
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
@@ -1923,9 +2081,10 @@ files = [
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -2150,9 +2309,10 @@ files = [
name = "rich"
version = "15.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
optional = true
python-versions = ">=3.9.0"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
@@ -2169,9 +2329,10 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
name = "rich-toolkit"
version = "0.20.1"
description = "Rich toolkit for building command-line applications"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "rich_toolkit-0.20.1-py3-none-any.whl", hash = "sha256:2a6d5f8e15759b9eba5a9ee63da10b275359ead20e5a0fc92bd5b4dbae8ce4bf"},
{file = "rich_toolkit-0.20.1.tar.gz", hash = "sha256:c7336ae281f435c785acecaedc4b71d4b663dc73d9c8079fea96372527e822a4"},
@@ -2186,9 +2347,10 @@ typing-extensions = ">=4.12.2"
name = "rignore"
version = "0.7.6"
description = "Python Bindings for the ignore crate"
optional = false
optional = true
python-versions = ">=3.8"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "rignore-0.7.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f3c74a7e5ee77aea669c95fdb3933f2a6c7549893700082e759128a29cf67e45"},
{file = "rignore-0.7.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b7202404958f5fe3474bac91f65350f0b1dde1a5e05089f2946549b7e91e79ec"},
@@ -2350,9 +2512,10 @@ files = [
name = "sentry-sdk"
version = "2.64.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
optional = true
python-versions = ">=3.6"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1"},
{file = "sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55"},
@@ -2414,9 +2577,10 @@ unleash = ["UnleashClient (>=6.0.1)"]
name = "shellingham"
version = "1.5.4"
description = "Tool to Detect Surrounding Shell"
optional = false
optional = true
python-versions = ">=3.7"
groups = ["app"]
groups = ["main"]
markers = "extra == \"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"},
@@ -2450,9 +2614,10 @@ files = [
name = "starlette"
version = "1.3.1"
description = "The little ASGI library that shines."
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"},
{file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"},
@@ -2480,9 +2645,10 @@ files = [
name = "typer"
version = "0.26.8"
description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c"},
{file = "typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e"},
@@ -2500,7 +2666,7 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "app"]
groups = ["main"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -2512,7 +2678,7 @@ version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main", "app"]
groups = ["main"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
@@ -2557,9 +2723,10 @@ testing = ["check_manifest", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-m
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
@@ -2575,9 +2742,10 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
name = "uvicorn"
version = "0.49.0"
description = "The lightning-fast ASGI server."
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f"},
{file = "uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"},
@@ -2601,10 +2769,10 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.8.0)
name = "uvloop"
version = "0.22.1"
description = "Fast implementation of asyncio event loop on top of libuv"
optional = false
optional = true
python-versions = ">=3.8.1"
groups = ["app"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
groups = ["main"]
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"app\""
files = [
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"},
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
@@ -2666,9 +2834,10 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil",
name = "watchfiles"
version = "1.2.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9"},
{file = "watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4"},
@@ -2786,9 +2955,10 @@ anyio = ">=3.0.0"
name = "websockets"
version = "16.0"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
optional = true
python-versions = ">=3.10"
groups = ["app"]
groups = ["main"]
markers = "extra == \"app\""
files = [
{file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
{file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
@@ -2972,7 +3142,10 @@ idna = ">=2.0"
multidict = ">=4.0"
propcache = ">=0.2.1"
[extras]
app = ["babel", "fastapi", "jinja2"]
[metadata]
lock-version = "2.1"
python-versions = "^3.14"
content-hash = "16dac910366fb78d67f45bac4c8cf19c0f448cb53fccb7e33c13ea22d509783f"
python-versions = ">=3.14,<4.0"
content-hash = "b0c7f9c6c9fbb20ada45e7d27601920b0ba42716a1a70b4e317e7295de230376"
+31 -29
View File
@@ -1,42 +1,44 @@
[tool.poetry]
[project]
name = "gallery"
version = "0.5.2"
description = ""
authors = ["shmyga <shmyga.z@gmail.com>"]
version = "0.7.0"
readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = '>=3.14,<4.0'
dependencies = [
'aiohttp (>=3.14,<4.0)',
'beautifulsoup4 (>=4.15,<5.0)',
'dateparser (>=1.4,<2.0)',
'pydantic (>=2.13,<3.0)',
'aiocache[redis] (>=0.12,<0.13)',
"playwright (>=1.62.0,<2.0.0)",
]
[project.optional-dependencies]
app = [
"fastapi[standard] (>=0.139,<0.140)",
"jinja2 (>=3.1,<4.0)",
"babel (>=2.18,<3.0)",
]
[dependency-groups]
test = ["pytest (>=9.1,<10.0)", "pytest-asyncio (>=1.4,<2.0)"]
dev = ["pylint (>=4.0.6,<5.0.0)", "black (>=26.5,<27.0)", "isort (>=8.0,<9.0)"]
[project.scripts]
gallery = "gallery.main:run"
[tool.poetry]
packages = [{ include = "gallery" }]
[tool.poetry.dependencies]
python = "^3.14"
aiohttp = "^3.14"
beautifulsoup4 = "^4.15"
dateparser = "^1.4"
pydantic = "^2.13"
aiocache = { extras = ["redis"], version = "^0.12" }
[tool.poetry.group.app.dependencies]
fastapi = {extras = ["standard"], version = "^0.139.0"}
jinja2 = "^3.1"
babel = "^2.18"
[tool.poetry.group.test.dependencies]
pytest = "^9.1"
pytest-asyncio = "^1.4"
[tool.poetry.group.dev.dependencies]
pylint = "^4.0"
black = "^26.5"
isort = "^8.0"
requires-poetry = '>=2.0.0,<3.0.0'
[build-system]
requires = ["poetry-core"]
requires = ['poetry-core (>=2.0.0,<3.0.0)']
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
gallery = "gallery.main:run"
[tool.black]
line-length = 120
target-version = ["py314"]
[tool.isort]
profile = "black"
+2 -2
View File
@@ -29,9 +29,9 @@ publish () {
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 tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
docker push $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
done
}
+1
View File
@@ -11,6 +11,7 @@ source .env
# python
poetry env use ${PYTHON_VERSION}
poetry install
.venv/bin/playwright install chromium --with-deps --only-shell
# static
cd static || exit
+2169 -35
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gallery",
"version": "0.5.2",
"version": "0.7.0",
"type": "module",
"scripts": {
"build": "webpack --mode production",
@@ -16,12 +16,19 @@
"flag-icons": "^7.5.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@types/bootstrap": "^5.2.11",
"@types/node": "^26.1.0",
"autoprefixer": "^10.5.4",
"babel-loader": "^10.1.1",
"copy-webpack-plugin": "^14.0.0",
"core-js": "^3.49.0",
"css-loader": "^7.1.4",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.10.2",
"postcss": "^8.5.22",
"postcss-loader": "^8.2.1",
"sass": "^1.101.0",
"sass-loader": "^17.0.0",
"style-loader": "^4.0.0",
@@ -30,5 +37,9 @@
"typescript": "^6.0.3",
"webpack": "^5.108.3",
"webpack-cli": "^7.1.0"
}
},
"browserslist": [
"> 0.5%",
"last 2 versions"
]
}
+1 -1
View File
@@ -62,6 +62,7 @@ class WeatherLocationElement extends HTMLElement {
<a href="${this.location?.provider}/${this.location?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="badge me-1 text-bg-secondary">${this.location?.provider}</span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary">
@@ -69,7 +70,6 @@ class WeatherLocationElement extends HTMLElement {
</span>
</span>
<span>
<span class="badge text-bg-secondary">${this.location?.provider}</span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
@@ -1,3 +1,4 @@
# pylint: skip-file
from pathlib import Path
from lxml import etree
@@ -17,8 +18,8 @@ def icon_optimizator(file: Path):
for node in tree.findall(".//{*}path"):
d = node.attrib["d"]
node.attrib["d"] = " ".join(map(normalize_value, d.split(" ")))
#file = file.parent.parent / "icons.tmp" / file.name
#file.parent.mkdir(parents=True, exist_ok=True)
# file = file.parent.parent / "icons.tmp" / file.name
# file.parent.mkdir(parents=True, exist_ok=True)
tree.write(file)
target_size = file.stat().st_size
print(file.name, source_size, target_size)
@@ -4,8 +4,12 @@ weather-icon,
.wi-svg {
display: flex;
width: 3rem;
height: 3rem;
> svg {
width: 3rem;
height: 3rem;
path {
fill: gray;
}
@@ -29,8 +33,20 @@ weather-icon,
&.wi-s {
width: 2.5rem;
height: 2.5rem;
> svg {
width: 2.5rem;
height: 2.5rem;
}
}
&.wi-l {
width: 3.5rem;
height: 3.5rem;
> svg {
width: 3.5rem;
height: 3.5rem;
}
}
}
+13 -5
View File
@@ -9,26 +9,34 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config: webpack.Configuration = {
target: ["web", "es6"],
entry: ["./src/index.ts", "./src/main.scss"],
module: {
rules: [
{ test: /\.svg$/, use: ["file-loader"] },
{
test: /\.tsx?$/,
use: {
loader: "ts-loader",
},
use: [{ loader: "babel-loader" }, { loader: "ts-loader" }],
exclude: /node_modules/,
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
{ loader: "css-loader", options: { sourceMap: true, url: false } },
{ loader: "css-loader", options: { sourceMap: false, url: false } },
{
loader: "postcss-loader",
options: {
sourceMap: false,
postcssOptions: {
plugins: [["autoprefixer", { grid: "autoplace" }]],
},
},
},
{
loader: "sass-loader",
options: {
sourceMap: true,
sourceMap: false,
sassOptions: { quietDeps: true, silenceDeprecations: ["color-functions", "global-builtin", "import"] },
},
},
+14 -1
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from typing import TypeVar
from gallery.sketch.api import Api, S
from gallery.sketch.source import ApiSource
@@ -10,8 +12,19 @@ class MockSource(ApiSource):
self._path = path
self._mapping = mapping
async def request(self, endpoint: str) -> str:
async def request(self, endpoint: str, /, **kwargs) -> str:
for pattern, filename in self._mapping.items():
if pattern in endpoint:
return (self._path / filename).read_text()
raise ValueError(endpoint)
API = Api[S]
A = TypeVar("A", bound=API)
def create_mock_api(api_type: type[A], mock_source: MockSource) -> A:
api = api_type()
# pylint: disable=protected-access
api._build_source = lambda: mock_source
return api
+20
View File
@@ -0,0 +1,20 @@
from os import environ
from aiocache import caches
import gallery # pylint:disable=unused-import
environ.setdefault("OPENWEATHER_KEY", "test")
caches.set_config(
{
"default": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
},
"redis": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
},
}
)
+7 -1
View File
@@ -1,8 +1,14 @@
from pathlib import Path
from gallery.painting.openweather.openweather import OpenWeather
from tests.common.mock import MockSource
OPENWEATHER_MOCK_SOURCE = MockSource(
class OpenWeatherMockSource(MockSource, OpenWeather):
pass
OPENWEATHER_MOCK_SOURCE = OpenWeatherMockSource(
Path(__file__).parent,
{
"forecast": "forecast.json",
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from tests.common.mock import MockSource
RP5_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"search": "search.html",
"test": "weather.html",
},
)
+379
View File
@@ -0,0 +1,379 @@
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml"><head>
<title>Результат поиска</title>
<script type="application/ld+json">
{
"dateModified": "2026-09-01"
}
</script>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="description" content="Результат поиска. Результаты поиска.">
<meta name="keywords" content="результат поиска, результаты поиска">
<meta name="viewport" content="height=device-height, user-scalable=yes">
<meta name="referrer" content="origin">
<meta name="msapplication-config" content="/browserconfig.xml">
<meta name="msapplication-TileColor" content="#005eb5">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_57x57.png" sizes="57x57">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_60x60.png" sizes="60x60">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_72x72.png" sizes="72x72">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_76x76.png" sizes="76x76">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_114x114.png" sizes="114x114">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_120x120.png" sizes="120x120">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_152x152.png" sizes="152x152">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_180x180.png" sizes="180x180">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/192x192.png" sizes="192x192">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/144x144.png" sizes="144x144">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/128x128.png" sizes="128x128">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/96x96.png" sizes="96x96">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/72x72.png" sizes="72x72">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/48x48.png" sizes="48x48">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/css/mainStyle.css?q=1.0182" type="text/css">
<link rel="stylesheet" href="/css/sprites.css?q=1.0182" type="text/css">
<link rel="stylesheet" href="/css/jquery.autocomplete.css?q=1.0182" type="text/css">
<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false&amp;key=AIzaSyAvCiizuGON7hMCTMa79Zf7d_4k56cCJ_0"></script>
<style media="print">
</style>
<script type="text/javascript" src="/scripts/jquery-1.6.4.min.js"></script>
<script type="text/javascript" src="/scripts/jquery.autocomplete.01.cr.js"></script>
<script type="text/javascript" src="/scripts/browser.js"></script>
<script type="text/javascript" src="/scripts/googlemap.js"></script>
<script type="text/javascript" src="/scripts/main.022.cr.js?q=1.052"></script>
<script type="text/javascript" src="/scripts/rp5hint.js"></script>
<script type="text/javascript" src="/scripts/rcs.js"></script>
<script type="text/javascript" src="/scripts/infobubble.001.js"></script>
<script type="text/javascript" src="/scripts/maps.008.js"></script>
<script type="text/javascript" src="/scripts/geo_vidget.js"></script>
<!--<script type="text/javascript" src="//static.criteo.net/js/ld/publishertag.js"></script>-->
<script>window.yaContextCb=window.yaContextCb||[]</script>
<script src="https://yandex.ru/ads/system/context.js" [^]="" async=""></script><script type="text/javascript" language="JavaScript">cRole = ""; langId = 2; cSearch = "Название города или села";</script>
<script type="text/javascript">
// локальное время
var gmtAdd = 10800000;
var sumHour = 0;
var cLocalTime = 'Местное время';
// установки времени
var Dphp = 1788293743;
var gmtServ = 0;
var Djs = Math.round(((D=new Date()).getTime()-D.getTimezoneOffset()*60000)/1000);
// дельта во времени в сек.
var tDelta = Dphp - Djs;
</script>
<style>
#h8c34381aaf77ce0fb634a3b6884f16046b9fd201 {display: inline-block; width: 126px; margin: 79px 0 0 8px;}
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {clear: both;}
@media print {
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {margin-top: 20px; background: #fff;}
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {display: none !important;}
}
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {float: left; font-size: 12px; display: inline; margin: 5px 0 11px 0;}
</style>
<script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/common.js"></script><script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/util.js"></script></head>
<body>
<div id="back-wrapper" style="width: 1670px; height: 688px;"></div>
<div id="message-outside" class="message-outside">
<div id="message-outside-content" class="message-outside-content"></div>
<div class="button-container">
<input type="button" class="button-blue" value="OK" onclick="displayBlocks('#message-outside','','',true); return false;">
</div>
</div>
<div id="map-screen-container">
<div id="map-screen-close"></div>
<div id="map-screen"></div>
</div>
<div id="wrapper">
<div id="header-wrapper" class="noprint">
<div id="header">
<table>
<tbody><tr>
<td rowspan="3" style="width: 180px;" class="td-top">
<div id="logo">
<a href="/Погода_в_мире" title=""><img src="/images/ru/logo.png" title="" alt="Погода_в_мире" style="margin: 4px 0 0 -17px"></a>
</div>
</td>
<td class="td-top">
<div id="topMenuContent" style="text-align: left">
<a id="mobile_link" class="pointer" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">Мобильная версия</a>|
<a href="/Погода_в_мире">Главная</a>|
<a href="/docs/about/ru">О сайте</a>|
<a href="/docs/faq/ru">Частые вопросы (FAQ)</a>|
<a href="/docs/contact/ru">Контакты</a>
</div>
</td>
</tr>
<tr>
<td style="height: 31px; vertical-align: middle"><div id="countries"><a href="/Погода_в_Беларуси">Беларусь</a>&nbsp;<a href="/Погода_в_Литве">Литва</a>&nbsp;<a href="/Погода_в_России">Россия</a>&nbsp;<a href="/Погода_в_Украине">Украина</a>&nbsp;<a href="/Погода_в_мире">Все страны</a></div>
<div class="rightNavi noprint">
<div class="intoNavi rss">
<span class="verticalBottom" id="RSS_top">RSS&nbsp; <a href="http://rp5.ru/rss/24530/ru" id="rss_link"><div class="iconrss"></div></a>&nbsp;</span>
</div>
</div>
</td>
</tr>
<tr>
<td style="padding-top: 7px; vertical-align: top">
<div id="telephone" class="buttonSummary" style="margin: 2px 2px 0 0;" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">
<table><tbody><tr><td style="height: 35px; padding: 0 34px 0 7px; vertical-align: middle; ">
<a class="inDd2Summary inDd2"><span>Мобильная версия</span></a>
</td></tr></tbody></table>
</div>
<div id="informerC" class="buttonSummary">
<div id="appButton" class="inDdSummary" style="font-size: 12px;">
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Приложения</td></tr></tbody></table>
</div>
<div id="appMenu" style="display: none;">
<ul>
<li onclick="top.location.href='/docs/informer/ru'">Информеры</li>
<li onclick="top.location.href='/docs/xml/ru?id=24530'">XML</li>
<li onclick="top.location.href='/docs/sgate/ru'">API частных метеостанций</li>
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
<li onclick="top.location.href='/docs/gadget/ru'">Гаджет для WINDOWS</li>
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
<li onclick="window.open('https://play.google.com/store/apps/details?id=ru.rp5.rp5weatherhorizontal', '_blank')">Приложение для Android</li>
<!--<li onclick="top.location.href='/wduck/source/new/app-release-v22.apk'">*.apk-файл</li>-->
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7;"></div>
<li onclick="top.location.href='/Дневник_погоды_в_Сватово'">Дневник погоды для школьников</li>
</ul>
</div>
</div>
<div class="ddListSummary">
<span>
<div class="ddList2">
<div id="unitsButton" class="inDdSummary">
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Единицы измерений</td></tr></tbody></table>
</div>
<div id="unitsMenu" style="text-align: left; display: none;">
<form name="fValues" action="">
<table id="tValues" style="vertical-align: top;">
<tbody><tr>
<td>
<div>Облачность:</div>
<div>
<input type="radio" id="r_cc1" class="radioUnits" name="r_cc" title="проценты (%)" onclick="if(this.checked) {chValue('cc',0,0);}" checked="checked">
<label for="r_cc1">проценты (%)</label>
</div>
<div>
<input type="radio" id="r_cc2" class="radioUnits" name="r_cc" title="1/10" onclick="if(this.checked) {chValue('cc',1,0);}">
<label for="r_cc2">1/10</label>
</div>
<div>
<input type="radio" id="r_cc3" class="radioUnits" name="r_cc" title="окты" onclick="if(this.checked) {chValue('cc',2,0);}">
<label for="r_cc3">окты</label>
</div>
</td>
<td>
<div>Осадки:</div>
<div>
<input type="radio" id="r_pr1" class="radioUnits" name="r_pr" title="миллиметры (мм)" onclick="if(this.checked) {chValue('pr',0,0);}" checked="checked">
<label for="r_pr1">миллиметры (мм) / сантиметры (см)</label>
</div>
<div>
<input type="radio" id="r_pr2" class="radioUnits" name="r_pr" title="дюймы" onclick="if(this.checked) {chValue('pr',1,0);}">
<label for="r_pr2">дюймы</label>
</div>
</td>
<td>
<div>Температура:</div>
<div>
<input type="radio" id="r_t1" class="radioUnits" name="r_t" title="градусы Цельсия (°C)" onclick="if(this.checked) {chValue('t',0,0);}" checked="checked">
<label for="r_t1">градусы Цельсия (°C)</label>
</div>
<div>
<input type="radio" id="r_t2" class="radioUnits" name="r_t" title="градусы Фаренгейта (°F)" onclick="if(this.checked) {chValue('t',1,0);}">
<label for="r_t2">градусы Фаренгейта (°F)</label>
</div>
</td>
</tr>
<tr>
<td colspan="2">
<div>Давление:</div>
<div>
<input type="radio" id="r_p1" class="radioUnits" name="r_p" title="миллиметры ртутного столба (мм рт. ст.)" onclick="if(this.checked) {chValue('p',0,0);}" checked="checked">
<label for="r_p1">миллиметры ртутного столба (мм рт. ст.)</label>
</div>
<div>
<input type="radio" id="r_p2" class="radioUnits" name="r_p" title="дюймы ртутного столба (дюймы рт. ст.)" onclick="if(this.checked) {chValue('p',1,0);}">
<label for="r_p2">дюймы ртутного столба (дюймы рт. ст.)</label>
</div>
<div>
<input type="radio" id="r_p3" class="radioUnits" name="r_p" title="миллибары (мбар)" onclick="if(this.checked) {chValue('p',2,0);}">
<label for="r_p3">миллибары (мбар)</label>
</div>
<div>
<input type="radio" id="r_p4" class="radioUnits" name="r_p" title="гектопаскали (гПа)" onclick="if(this.checked) {chValue('p',3,0);}">
<label for="r_p3">гектопаскали (гПа)</label>
</div>
</td>
<td>
<div>Ветер:</div>
<div>
<input type="radio" id="r_wv1" class="radioUnits" name="r_wv" title="метры в секунду (м/с)" onclick="if(this.checked) {chValue('wv',0,0);}" checked="checked">
<label for="r_wv1">метры в секунду (м/с)</label>
</div>
<div>
<input type="radio" id="r_wv2" class="radioUnits" name="r_wv" title="километры в час (км/ч)" onclick="if(this.checked) {chValue('wv',1,0);}">
<label for="r_wv2">километры в час (км/ч)</label>
</div>
<div>
<input type="radio" id="r_wv3" class="radioUnits" name="r_wv" title="мили в час (мили/час)" onclick="if(this.checked) {chValue('wv',2,0);}">
<label for="r_wv3">мили в час (мили/час)</label>
</div>
<div>
<input type="radio" id="r_wv4" class="radioUnits" name="r_wv" title="узлы" onclick="if(this.checked) {chValue('wv',3,0);}">
<label for="r_wv4">узлы</label>
</div>
<div>
<input type="radio" id="r_wv5" class="radioUnits" name="r_wv" title="Бфрт (Бфрт)" onclick="if(this.checked) {chValue('wv',4,0);}">
<label for="r_wv5">баллы Бофорта (Бфрт)</label>
</div>
</td>
</tr>
<tr>
<td colspan="3">
<div style="margin-bottom: 9px; border-top: 1px dashed #a7a7a7"></div>
<div>Только для страниц «Архив погоды»</div>
<div>Горизонтальная дальность видимости (VV):</div>
<div>
<input type="radio" id="r_vv1" class="radioUnits" name="r_vv" title="километры" onclick="if(this.checked) {chValue('vv',0,0);}" checked="checked">
<label for="r_vv1">километры</label>
</div>
<div>
<input type="radio" id="r_vv2" class="radioUnits" name="r_vv" title="мили" onclick="if(this.checked) {chValue('vv',1,0);}">
<label for="r_vv2">мили</label>
</div>
<div>Высота основания самых низких облаков (H):</div>
<div>
<input type="radio" id="r_h1" class="radioUnits" name="r_h" title="метры" onclick="if(this.checked) {chValue('h',0,0);}" checked="checked">
<label for="r_h1">метры</label>
</div>
<div>
<input type="radio" id="r_h2" class="radioUnits" name="r_h" title="футы" onclick="if(this.checked) {chValue('h',1,0);}">
<label for="r_h2">футы</label>
</div>
<div>Высота снежного покрова (sss):</div>
<div>
<input type="radio" id="r_sss1" class="radioUnits" name="r_sss" title="сантиметры" onclick="if(this.checked) {chValue('sss',0,0);}" checked="checked">
<label for="r_sss1">сантиметры</label>
</div>
<div>
<input type="radio" id="r_sss2" class="radioUnits" name="r_sss" title="дюймы" onclick="if(this.checked) {chValue('sss',1,0);}">
<label for="r_sss2">дюймы</label>
</div>
</td>
</tr>
</tbody></table>
</form>
</div>
</div>
</span>
</div>
<div class="ddListSummary1">
<span>
<div class="ddList1">
<div id="langButton" class="inDdSummary">
<table><tbody><tr><td style="height: 35px; vertical-align: middle;">Language</td></tr></tbody></table>
</div>
<div id="langMenu" style="display: none;">
<ul></ul>
</div>
</div>
</span>
</div>
<div id="search">
<form name="fsearch" autocomplete="off" method="post" action="/docs/search/ru" onsubmit="searchConfirm();">
<input type="text" name="searchStr" id="searchStr" class="search ac_input" onfocus="clearTxt();" onblur="setTxt();" value="змиёвка" autocomplete="off">
<input type="button" id="searchButton" class="searchButton" onclick="searchConfirm();" value="">
<script type="text/javascript">setTxt();</script>
</form>
</div>
</td>
</tr>
</tbody></table>
</div>
</div>
<div id="content-wrapper">
<div id="content">
<div id="hf420d4b9b1efdbbe6ef03d062299d020ccae86d6">
<div class="intoLeftNavi">
<span class="verticalBottom">
<a href="/Погода_в_мире"><nobr>Все страны</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;<a href="/Погода_в_России"><nobr>Россия</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Луганской_Народной_Республике"><nobr>Луганская Народная Республика</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Сватовском_районе"><nobr>Сватовский район</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Змиевке,_Луганская_Народная_Республика"><nobr>Змиевка</nobr></a>
</span>
</div>
</div>
<div id="ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7"><div id="leftNavi" class="pageTitle"> <span class="verticalBottom"><h1>Результат поиска</h1></span></div><div class="clear"></div> <p class="stinfo"><b>Найдено населенных пунктов:</b> 3</p> <table cellspacing="0" cellpadding="0" class="searchResults"> <tbody><tr class="header"> <td> <div class="ImgLeft"></div> <div class="searchPointTitle"> <span class="innerTextHead">Населенный пункт</span> <div> </div></div></td> <td> <div class="searchTitles"> <span class="innerTextHead">Страна</span> </div> </td> <td> <div class="searchTitles"> <span class="innerTextHead">Регион</span> </div> </td> <td style="border-right: none;"> <div class="ImgRight"></div> <div class="searchTitles"> <span class="innerTextHead">Район</span> </div> </td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Луганская Народная Республика</span></td> <td><span class="innerTextResult">Сватовский район</span></td> </tr><tr class="srow1"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Орловская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Орловская область</span></td> <td><span class="innerTextResult">Свердловский район</span></td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Херсонская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Херсонская область</span></td> <td><span class="innerTextResult">Бериславский район</span></td> </tr> </tbody></table>
</div>
</div>
<div class="clear"></div>
</div>
</div>
<div id="footer-wrapper" class="noprint">
<div id="footer">
<div id="footer-info">
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Показаны ранее:</b></div><span class="Ajax-PointID" id="6318">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+19</span></span>
<span class="t_1" style="display: none;"><span class="red">+66</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Орле,_Орловская_область">Орел</a></div><br><span class="Ajax-PointID" id="24530">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></div><br>
</div>
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Ближайшие центры:</b></div><span class="Ajax-PointID" id="531">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Сватово,_Луганская_Народная_Республика">Сватово</a></div><br><span class="Ajax-PointID" id="720">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+15</span></span>
<span class="t_1" style="display: none;"><span class="red">+59</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Боровой,_Боровский_район">Боровая</a></div><br><span class="Ajax-PointID" id="735">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Купянске">Купянск</a></div><br><span class="Ajax-PointID" id="518">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Кременной,_Россия">Кременная</a></div><br>
</div>
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Популярные:</b></div><span class="Ajax-PointID" id="488">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+13</span></span>
<span class="t_1" style="display: none;"><span class="red">+55</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Беловодске">Беловодск</a></div><br><span class="Ajax-PointID" id="520">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Луганске,_Луганская_Народная_Республика">Луганск</a></div><br><span class="Ajax-PointID" id="529">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+21</span></span>
<span class="t_1" style="display: none;"><span class="red">+70</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Ровеньках,_Луганская_Народная_Республика">Ровеньки</a></div><br><span class="Ajax-PointID" id="536">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Старобельске">Старобельск</a></div><br>
</div>
</div>
<div id="footer-content">
<div id="footer-copyright">Copyright © ООО «Расписание Погоды», 2004-2026</div>
</div>
</div>
</div>
<!-- old code -->
</body></html>
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from tests.common.mock import MockSource
YANDEXWEATHER_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"details": "details.html",
"suggest": "suggest.json",
},
)
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
[
{
"title": "Орёл",
"description": "Орёл",
"coords": { "lat": 52.97075653, "lon": 36.06435776 },
"uri": "//yandex.ru/pogoda?name=%D0%9E%D1%80%D1%91%D0%BB&kind=locality",
"geoId": 10,
"slug": "10",
"kind": "locality",
"distance": { "value": null, "text": "" },
"url": "//yandex.ru/pogoda?name=%D0%9E%D1%80%D1%91%D0%BB&kind=locality"
},
{
"title": "село Орёл-Изумруд",
"description": "Краснодарский край",
"coords": { "lat": 43.46733475, "lon": 39.92550659 },
"uri": "//yandex.ru/pogoda?name=%D0%9A%D1%80%D0%B0%D1%81%D0%BD%D0%BE%D0%B4%D0%B0%D1%80%D1%81%D0%BA%D0%B8%D0%B9+%D0%BA%D1%80%D0%B0%D0%B9%2C+%D0%B3%D0%BE%D1%80%D0%BE%D0%B4%D1%81%D0%BA%D0%BE%D0%B9+%D0%BE%D0%BA%D1%80%D1%83%D0%B3+%D0%A1%D0%BE%D1%87%D0%B8%2C+%D1%81%D0%B5%D0%BB%D0%BE+%D0%9E%D1%80%D1%91%D0%BB-%D0%98%D0%B7%D1%83%D0%BC%D1%80%D1%83%D0%B4&kind=locality",
"geoId": 211278,
"slug": "211278",
"kind": "locality",
"distance": { "value": null, "text": "" },
"url": "//yandex.ru/pogoda?name=%D0%9A%D1%80%D0%B0%D1%81%D0%BD%D0%BE%D0%B4%D0%B0%D1%80%D1%81%D0%BA%D0%B8%D0%B9+%D0%BA%D1%80%D0%B0%D0%B9%2C+%D0%B3%D0%BE%D1%80%D0%BE%D0%B4%D1%81%D0%BA%D0%BE%D0%B9+%D0%BE%D0%BA%D1%80%D1%83%D0%B3+%D0%A1%D0%BE%D1%87%D0%B8%2C+%D1%81%D0%B5%D0%BB%D0%BE+%D0%9E%D1%80%D1%91%D0%BB-%D0%98%D0%B7%D1%83%D0%BC%D1%80%D1%83%D0%B4&kind=locality"
},
{
"title": "деревня Орёл",
"description": "Республика Татарстан (Татарстан)",
"coords": { "lat": 55.56605148, "lon": 49.06790161 },
"uri": "//yandex.ru/pogoda?name=%D0%A0%D0%B5%D1%81%D0%BF%D1%83%D0%B1%D0%BB%D0%B8%D0%BA%D0%B0+%D0%A2%D0%B0%D1%82%D0%B0%D1%80%D1%81%D1%82%D0%B0%D0%BD+%28%D0%A2%D0%B0%D1%82%D0%B0%D1%80%D1%81%D1%82%D0%B0%D0%BD%29%2C+%D0%9B%D0%B0%D0%B8%D1%88%D0%B5%D0%B2%D1%81%D0%BA%D0%B8%D0%B9+%D1%80%D0%B0%D0%B9%D0%BE%D0%BD%2C+%D0%9E%D1%80%D0%BB%D0%BE%D0%B2%D1%81%D0%BA%D0%BE%D0%B5+%D1%81%D0%B5%D0%BB%D1%8C%D1%81%D0%BA%D0%BE%D0%B5+%D0%BF%D0%BE%D1%81%D0%B5%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5%2C+%D0%B4%D0%B5%D1%80%D0%B5%D0%B2%D0%BD%D1%8F+%D0%9E%D1%80%D1%91%D0%BB&kind=locality",
"geoId": 140917,
"slug": "140917",
"kind": "locality",
"distance": { "value": null, "text": "" },
"url": "//yandex.ru/pogoda?name=%D0%A0%D0%B5%D1%81%D0%BF%D1%83%D0%B1%D0%BB%D0%B8%D0%BA%D0%B0+%D0%A2%D0%B0%D1%82%D0%B0%D1%80%D1%81%D1%82%D0%B0%D0%BD+%28%D0%A2%D0%B0%D1%82%D0%B0%D1%80%D1%81%D1%82%D0%B0%D0%BD%29%2C+%D0%9B%D0%B0%D0%B8%D1%88%D0%B5%D0%B2%D1%81%D0%BA%D0%B8%D0%B9+%D1%80%D0%B0%D0%B9%D0%BE%D0%BD%2C+%D0%9E%D1%80%D0%BB%D0%BE%D0%B2%D1%81%D0%BA%D0%BE%D0%B5+%D1%81%D0%B5%D0%BB%D1%8C%D1%81%D0%BA%D0%BE%D0%B5+%D0%BF%D0%BE%D1%81%D0%B5%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5%2C+%D0%B4%D0%B5%D1%80%D0%B5%D0%B2%D0%BD%D1%8F+%D0%9E%D1%80%D1%91%D0%BB&kind=locality"
},
{
"title": "посёлок Орёл",
"description": "Пермский край",
"coords": { "lat": 59.33684921, "lon": 56.58647919 },
"uri": "//yandex.ru/pogoda?name=%D0%9F%D0%B5%D1%80%D0%BC%D1%81%D0%BA%D0%B8%D0%B9+%D0%BA%D1%80%D0%B0%D0%B9%2C+%D0%BC%D1%83%D0%BD%D0%B8%D1%86%D0%B8%D0%BF%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9+%D0%BE%D0%BA%D1%80%D1%83%D0%B3+%D0%91%D0%B5%D1%80%D0%B5%D0%B7%D0%BD%D0%B8%D0%BA%D0%B8%2C+%D0%BF%D0%BE%D1%81%D1%91%D0%BB%D0%BE%D0%BA+%D0%9E%D1%80%D1%91%D0%BB&kind=locality",
"geoId": 135862,
"slug": "135862",
"kind": "locality",
"distance": { "value": null, "text": "" },
"url": "//yandex.ru/pogoda?name=%D0%9F%D0%B5%D1%80%D0%BC%D1%81%D0%BA%D0%B8%D0%B9+%D0%BA%D1%80%D0%B0%D0%B9%2C+%D0%BC%D1%83%D0%BD%D0%B8%D1%86%D0%B8%D0%BF%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9+%D0%BE%D0%BA%D1%80%D1%83%D0%B3+%D0%91%D0%B5%D1%80%D0%B5%D0%B7%D0%BD%D0%B8%D0%BA%D0%B8%2C+%D0%BF%D0%BE%D1%81%D1%91%D0%BB%D0%BE%D0%BA+%D0%9E%D1%80%D1%91%D0%BB&kind=locality"
},
{
"title": "Пуэрто-Франсиско-де-Орельяна",
"description": "Провинция Орельяна",
"coords": { "lat": -0.4602830112, "lon": -76.99185944 },
"uri": "//yandex.ru/pogoda?name=%D0%9F%D1%80%D0%BE%D0%B2%D0%B8%D0%BD%D1%86%D0%B8%D1%8F+%D0%9E%D1%80%D0%B5%D0%BB%D1%8C%D1%8F%D0%BD%D0%B0%2C+%D0%9F%D1%83%D1%8D%D1%80%D1%82%D0%BE-%D0%A4%D1%80%D0%B0%D0%BD%D1%81%D0%B8%D1%81%D0%BA%D0%BE-%D0%B4%D0%B5-%D0%9E%D1%80%D0%B5%D0%BB%D1%8C%D1%8F%D0%BD%D0%B0&kind=weather",
"geoId": 111891,
"slug": "111891",
"kind": "weather",
"distance": { "value": null, "text": "" },
"url": "//yandex.ru/pogoda?name=%D0%9F%D1%80%D0%BE%D0%B2%D0%B8%D0%BD%D1%86%D0%B8%D1%8F+%D0%9E%D1%80%D0%B5%D0%BB%D1%8C%D1%8F%D0%BD%D0%B0%2C+%D0%9F%D1%83%D1%8D%D1%80%D1%82%D0%BE-%D0%A4%D1%80%D0%B0%D0%BD%D1%81%D0%B8%D1%81%D0%BA%D0%BE-%D0%B4%D0%B5-%D0%9E%D1%80%D0%B5%D0%BB%D1%8C%D1%8F%D0%BD%D0%B0&kind=weather"
}
]
+2 -3
View File
@@ -3,14 +3,13 @@ import datetime
import pytest
from gallery.painting.gismeteo.api import GismeteoApi
from tests.common.mock import create_mock_api
from tests.data.gismeteo import GISMETEO_MOCK_SOURCE
@pytest.fixture(name="gismeteo_api", scope="module")
def gismeteo_api_fixture() -> GismeteoApi:
api = GismeteoApi()
api.SOURCE = GISMETEO_MOCK_SOURCE
return api
return create_mock_api(GismeteoApi, GISMETEO_MOCK_SOURCE)
async def test_search(gismeteo_api: GismeteoApi):
+2 -3
View File
@@ -3,14 +3,13 @@ import datetime
import pytest
from gallery.painting.matchtv.api import MatchTvApi
from tests.common.mock import create_mock_api
from tests.data.matchtv import MATCHTV_MOCK_SOURCE
@pytest.fixture(name="matchtv_api", scope="module")
def matchtv_api_fixture() -> MatchTvApi:
api = MatchTvApi()
api.SOURCE = MATCHTV_MOCK_SOURCE
return api
return create_mock_api(MatchTvApi, MATCHTV_MOCK_SOURCE)
async def test_search(matchtv_api: MatchTvApi):
+2 -9
View File
@@ -3,20 +3,13 @@ import datetime
import pytest
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.openweather.openweather import OpenWeather
from tests.common.mock import create_mock_api
from tests.data.openweather import OPENWEATHER_MOCK_SOURCE
@pytest.fixture(name="openweather_api", scope="module")
def openweather_api_fixture() -> OpenWeatherApi:
class MockOpenWeather(OpenWeather):
def __init__(self):
super().__init__("")
self._source = OPENWEATHER_MOCK_SOURCE
api = OpenWeatherApi()
api.SOURCE = MockOpenWeather()
return api
return create_mock_api(OpenWeatherApi, OPENWEATHER_MOCK_SOURCE)
async def test_search(openweather_api: OpenWeatherApi):
+27
View File
@@ -0,0 +1,27 @@
import datetime
import pytest
from gallery.painting.rp5.api import RP5WeatherApi
from tests.common.mock import create_mock_api
from tests.data.rp5 import RP5_MOCK_SOURCE
@pytest.fixture(name="rp5_api", scope="module")
def rp5_api_fixture() -> RP5WeatherApi:
return create_mock_api(RP5WeatherApi, RP5_MOCK_SOURCE)
async def test_search(rp5_api: RP5WeatherApi):
result = await rp5_api.find_locations("test")
assert len(result) == 3
async def test_day(rp5_api: RP5WeatherApi):
result = await rp5_api.get_day("test", datetime.date(2026, 9, 2))
assert len(result.values) == 4
async def test_days(rp5_api: RP5WeatherApi):
result = await rp5_api.get_days("test", 10)
assert len(result.values) == 7
+2 -3
View File
@@ -3,14 +3,13 @@ import datetime
import pytest
from gallery.painting.yandextv.api import YandexTvApi
from tests.common.mock import create_mock_api
from tests.data.yandextv import YANDEXTV_MOCK_SOURCE
@pytest.fixture(name="yandextv_api", scope="module")
def yandextv_api_fixture() -> YandexTvApi:
api = YandexTvApi()
api.SOURCE = YANDEXTV_MOCK_SOURCE
return api
return create_mock_api(YandexTvApi, YANDEXTV_MOCK_SOURCE)
async def test_search(yandextv_api: YandexTvApi):
+27
View File
@@ -0,0 +1,27 @@
import datetime
import pytest
from gallery.painting.yandexweather.api import YandexWeatherApi
from tests.common.mock import create_mock_api
from tests.data.yandexweather import YANDEXWEATHER_MOCK_SOURCE
@pytest.fixture(name="yandexweather_api", scope="module")
def yandexweather_api_fixture() -> YandexWeatherApi:
return create_mock_api(YandexWeatherApi, YANDEXWEATHER_MOCK_SOURCE)
async def test_search(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.find_locations("test")
assert len(result) == 5
async def test_day(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.get_day("test", datetime.date.today())
assert len(result.values) == 4
async def test_days(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.get_days("test", 10)
assert len(result.values) == 10