From 8c7a18a8e27813cdb4ec5ff96ba10908b42b5f94 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 21:57:54 +0200 Subject: [PATCH 01/13] Postgres --- alembic_/env.py | 61 ++ alembic_/script.py.mako | 27 + ..._02_1819-9f60c90e8a21_initial_migration.py | 89 ++ api/v1/countries.py | 40 +- api/v1/node.py | 22 +- api/v1/photos.py | 74 +- api/v1/tile.py | 14 +- config.py | 111 +-- config/alembic.ini | 121 +++ config/postgres.conf | 65 ++ config/supervisord.conf | 16 + default.vcl => config/varnish.vcl | 2 +- country_code_assigner.py | 19 + db.py | 42 + default.nix | 43 +- docker-compose.dev.yml | 23 - docker-compose.yml | 58 +- json_response.py | 5 +- main.py | 20 +- models/aed.py | 20 - models/bbox.py | 2 - models/country.py | 23 - models/db/__init__.py | 5 + models/db/aed.py | 36 + models/db/base.py | 5 + models/db/country.py | 31 + models/db/created_at_mixin.py | 14 + models/db/photo.py | 28 + models/db/photo_report.py | 28 + models/db/state.py | 17 + models/geometry.py | 67 ++ models/osm_country.py | 4 +- models/photo_info.py | 17 - models/photo_report.py | 9 - openstreetmap.py | 9 +- osm_countries.py | 16 +- planet_diffs.py | 76 +- poetry.lock | 918 +++++++++++------- pyproject.toml | 15 +- scripts/mongo-init-replica.sh | 16 - services/aed_service.py | 309 ++++++ services/country_service.py | 127 +++ services/photo_report_service.py | 34 + .../photo_service.py | 95 +- services/state_service.py | 28 + services/worker_service.py | 57 ++ shell.nix | 95 +- state_utils.py | 13 - states/aed_state.py | 347 ------- states/country_state.py | 173 ---- states/photo_report_state.py | 47 - states/worker_state.py | 55 -- transaction.py | 14 - utils.py | 40 +- validators/geometry.py | 23 - 55 files changed, 2143 insertions(+), 1522 deletions(-) create mode 100644 alembic_/env.py create mode 100644 alembic_/script.py.mako create mode 100644 alembic_/versions/2024_04_02_1819-9f60c90e8a21_initial_migration.py create mode 100644 config/alembic.ini create mode 100644 config/postgres.conf create mode 100644 config/supervisord.conf rename default.vcl => config/varnish.vcl (98%) create mode 100644 country_code_assigner.py create mode 100644 db.py delete mode 100644 docker-compose.dev.yml delete mode 100644 models/aed.py delete mode 100644 models/country.py create mode 100644 models/db/__init__.py create mode 100644 models/db/aed.py create mode 100644 models/db/base.py create mode 100644 models/db/country.py create mode 100644 models/db/created_at_mixin.py create mode 100644 models/db/photo.py create mode 100644 models/db/photo_report.py create mode 100644 models/db/state.py create mode 100644 models/geometry.py delete mode 100644 models/photo_info.py delete mode 100644 models/photo_report.py delete mode 100644 scripts/mongo-init-replica.sh create mode 100644 services/aed_service.py create mode 100644 services/country_service.py create mode 100644 services/photo_report_service.py rename states/photo_state.py => services/photo_service.py (54%) create mode 100644 services/state_service.py create mode 100644 services/worker_service.py delete mode 100644 state_utils.py delete mode 100644 states/aed_state.py delete mode 100644 states/country_state.py delete mode 100644 states/photo_report_state.py delete mode 100644 states/worker_state.py delete mode 100644 transaction.py delete mode 100644 validators/geometry.py diff --git a/alembic_/env.py b/alembic_/env.py new file mode 100644 index 0000000..9014be4 --- /dev/null +++ b/alembic_/env.py @@ -0,0 +1,61 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import create_async_engine + +from config import POSTGRES_URL +from models.db import * # noqa: F403 +from models.db.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = create_async_engine(POSTGRES_URL) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +run_migrations_online() diff --git a/alembic_/script.py.mako b/alembic_/script.py.mako new file mode 100644 index 0000000..bac36b7 --- /dev/null +++ b/alembic_/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Text +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic_/versions/2024_04_02_1819-9f60c90e8a21_initial_migration.py b/alembic_/versions/2024_04_02_1819-9f60c90e8a21_initial_migration.py new file mode 100644 index 0000000..df95edc --- /dev/null +++ b/alembic_/versions/2024_04_02_1819-9f60c90e8a21_initial_migration.py @@ -0,0 +1,89 @@ +"""Initial migration + +Revision ID: 9f60c90e8a21 +Revises: +Create Date: 2024-04-02 18:19:53.332510+00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +import models.geometry + +# revision identifiers, used by Alembic. +revision: str = '9f60c90e8a21' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute('CREATE EXTENSION IF NOT EXISTS postgis;') + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'aed', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('version', sa.BigInteger(), nullable=False), + sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('position', models.geometry.PointType(), nullable=False), + sa.Column('country_codes', sa.ARRAY(sa.Unicode(length=8), dimensions=1), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('aed_country_codes_idx', 'aed', ['country_codes'], unique=False, postgresql_using='gin') + op.create_index('aed_position_idx', 'aed', ['position'], unique=False, postgresql_using='gist') + op.create_table( + 'country', + sa.Column('code', sa.Unicode(length=8), nullable=False), + sa.Column('names', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('geometry', models.geometry.PolygonType(), nullable=False), + sa.Column('label_position', models.geometry.PointType(), nullable=False), + sa.PrimaryKeyConstraint('code'), + ) + op.create_index('country_geometry_idx', 'country', ['geometry'], unique=False, postgresql_using='gist') + op.create_table( + 'photo', + sa.Column('id', sa.Unicode(length=32), nullable=False), + sa.Column('node_id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column( + 'created_at', + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text('statement_timestamp()'), + nullable=False, + ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_table( + 'state', + sa.Column('key', sa.Unicode(), nullable=False), + sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint('key'), + ) + op.create_table( + 'photo_report', + sa.Column('id', sa.Unicode(length=32), nullable=False), + sa.Column('photo_id', sa.Unicode(length=32), nullable=False), + sa.Column( + 'created_at', + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text('statement_timestamp()'), + nullable=False, + ), + sa.ForeignKeyConstraint( + ['photo_id'], + ['photo.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('photo_report_created_at_idx', 'photo_report', ['created_at'], unique=False) + op.create_index('photo_report_photo_id_idx', 'photo_report', ['photo_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + pass diff --git a/api/v1/countries.py b/api/v1/countries.py index 851ceee..1cf4345 100644 --- a/api/v1/countries.py +++ b/api/v1/countries.py @@ -4,14 +4,13 @@ from anyio import create_task_group from fastapi import APIRouter, Path from sentry_sdk import start_span -from shapely.geometry import mapping +from shapely import get_coordinates from middlewares.cache_middleware import configure_cache from middlewares.skip_serialization import skip_serialization -from models.country import Country -from states.aed_state import AEDState -from states.country_state import CountryState -from utils import simple_point_mapping +from models.db.country import Country +from services.aed_service import AEDService +from services.country_service import CountryService router = APIRouter(prefix='/countries') @@ -20,40 +19,40 @@ @configure_cache(timedelta(hours=1), stale=timedelta(days=7)) @skip_serialization() async def get_names(language: str | None = None): - countries = await CountryState.get_all_countries() + countries = await CountryService.get_all() country_count_map: dict[str, int] = {} with start_span(description='Counting AEDs'): async def count_task(country: Country) -> None: - count = await AEDState.count_aeds_by_country_code(country.code) - country_count_map[country.name] = count + count = await AEDService.count_by_country_code(country.code) + country_count_map[country.code] = count async with create_task_group() as tg: for country in countries: tg.start_soon(count_task, country) - def limit_country_names(names: dict[str, str]): - if language and (name := names.get(language)): - return {language: name} - return names + def limit_country_names(names: dict[str, str]) -> dict[str, str]: + return {language: name} if (language and (name := names.get(language))) else names - return [ + result = [ { 'country_code': country.code, 'country_names': limit_country_names(country.names), - 'feature_count': country_count_map[country.name], + 'feature_count': country_count_map[country.code], 'data_path': f'/api/v1/countries/{country.code}.geojson', } for country in countries - ] + [ + ] + result.append( { 'country_code': 'WORLD', 'country_names': {'default': 'World'}, 'feature_count': sum(country_count_map.values()), 'data_path': '/api/v1/countries/WORLD.geojson', } - ] + ) + return result @router.get('/{country_code}.geojson') @@ -66,16 +65,19 @@ def limit_country_names(names: dict[str, str]): ) async def get_geojson(country_code: Annotated[str, Path(min_length=2, max_length=5)]): if country_code == 'WORLD': - aeds = await AEDState.get_all_aeds() + aeds = await AEDService.get_all() else: - aeds = await AEDState.get_aeds_by_country_code(country_code) + aeds = await AEDService.get_by_country_code(country_code) return { 'type': 'FeatureCollection', 'features': [ { 'type': 'Feature', - 'geometry': simple_point_mapping(aed.position), + 'geometry': { + 'type': 'Point', + 'coordinates': get_coordinates(aed.position)[0].tolist(), + }, 'properties': { '@osm_type': 'node', '@osm_id': aed.id, diff --git a/api/v1/node.py b/api/v1/node.py index e0b486f..f2afd8f 100644 --- a/api/v1/node.py +++ b/api/v1/node.py @@ -2,15 +2,15 @@ from datetime import datetime, timedelta from urllib.parse import quote_plus -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Response from pytz import timezone from shapely import get_coordinates from tzfpy import get_tz from middlewares.cache_middleware import configure_cache from middlewares.skip_serialization import skip_serialization -from states.aed_state import AEDState -from states.photo_state import PhotoState +from services.aed_service import AEDService +from services.photo_service import PhotoService from utils import get_wikimedia_commons_url router = APIRouter() @@ -40,11 +40,11 @@ async def _get_image_data(tags: dict[str, str]) -> dict: image_url and (photo_id_match := photo_id_re.search(image_url)) and (photo_id := photo_id_match.group('id')) - and (photo_info := await PhotoState.get_photo_by_id(photo_id)) + and (await PhotoService.get_by_id(photo_id)) is not None ): return { - '@photo_id': photo_info.id, - '@photo_url': f'/api/v1/photos/view/{photo_info.id}.webp', + '@photo_id': photo_id, + '@photo_url': f'/api/v1/photos/view/{photo_id}.webp', '@photo_source': None, } @@ -75,15 +75,13 @@ async def _get_image_data(tags: dict[str, str]) -> dict: @configure_cache(timedelta(minutes=1), stale=timedelta(minutes=5)) @skip_serialization() async def get_node(node_id: int): - aed = await AEDState.get_aed_by_id(node_id) - + aed = await AEDService.get_by_id(node_id) if aed is None: - raise HTTPException(404, f'Node {node_id} not found') - - x, y = get_coordinates(aed.position)[0].tolist() + return Response(f'Node {node_id} not found', 404) photo_dict = await _get_image_data(aed.tags) + x, y = get_coordinates(aed.position)[0].tolist() timezone_name, timezone_offset = _get_timezone(x, y) timezone_dict = { '@timezone_name': timezone_name, @@ -100,7 +98,7 @@ async def get_node(node_id: int): **photo_dict, **timezone_dict, 'type': 'node', - 'id': aed.id, + 'id': node_id, 'lat': y, 'lon': x, 'tags': aed.tags, diff --git a/api/v1/photos.py b/api/v1/photos.py index 9560c38..657cc03 100644 --- a/api/v1/photos.py +++ b/api/v1/photos.py @@ -8,18 +8,15 @@ from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import FileResponse from feedgen.feed import FeedGenerator -from msgspec.json import Decoder -from config import IMAGE_CONTENT_TYPES, REMOTE_IMAGE_MAX_FILE_SIZE +from config import IMAGE_CONTENT_TYPES, IMAGE_REMOTE_MAX_FILE_SIZE from middlewares.cache_middleware import configure_cache from openstreetmap import OpenStreetMap, osm_user_has_active_block from osm_change import update_node_tags_osm_change -from states.aed_state import AEDState -from states.photo_report_state import PhotoReportState -from states.photo_state import PhotoState -from utils import get_http_client, get_wikimedia_commons_url - -_json_decode = Decoder().decode +from services.aed_service import AEDService +from services.photo_report_service import PhotoReportService +from services.photo_service import PhotoService +from utils import JSON_DECODE, get_http_client, get_wikimedia_commons_url router = APIRouter(prefix='/photos') @@ -38,8 +35,8 @@ async def _fetch_image(url: str) -> tuple[bytes, str]: with BytesIO() as buffer: async for chunk in r.aiter_bytes(chunk_size=1024 * 1024): buffer.write(chunk) - if buffer.tell() > REMOTE_IMAGE_MAX_FILE_SIZE: - raise HTTPException(500, f'File is too large, max allowed size is {REMOTE_IMAGE_MAX_FILE_SIZE} bytes') + if buffer.tell() > IMAGE_REMOTE_MAX_FILE_SIZE: + raise HTTPException(500, f'File is too large, max allowed size is {IMAGE_REMOTE_MAX_FILE_SIZE} bytes') file = buffer.getvalue() @@ -54,12 +51,11 @@ async def _fetch_image(url: str) -> tuple[bytes, str]: @router.get('/view/{id}.webp') @configure_cache(timedelta(days=365), stale=timedelta(days=365)) async def view(id: str): - info = await PhotoState.get_photo_by_id(id) - - if info is None: - raise HTTPException(404, f'Photo {id!r} not found') + photo = await PhotoService.get_by_id(id) + if photo is None: + return Response(f'Photo {id!r} not found', 404) - return FileResponse(info.path) + return FileResponse(photo.file_path) @router.get('/proxy/direct/{url_encoded:path}') @@ -82,7 +78,7 @@ async def proxy_wikimedia_commons(path_encoded: str): bs = BeautifulSoup(r.text, 'lxml') og_image = bs.find('meta', property='og:image') if not og_image: - raise HTTPException(404, 'Missing og:image meta tag') + return Response('Missing og:image meta tag', 404) image_url = og_image['content'] file, content_type = await _fetch_image(image_url) @@ -101,37 +97,35 @@ async def upload( accept_licenses = ('CC0',) if file_license not in accept_licenses: - raise HTTPException(400, f'Unsupported license {file_license!r}, must be one of {accept_licenses}') + return Response(f'Unsupported license {file_license!r}, must be one of {accept_licenses}', 400) if file.size <= 0: - raise HTTPException(400, 'File must not be empty') + return Response('File must not be empty', 400) content_type = magic.from_buffer(file.file.read(2048), mime=True) if content_type not in IMAGE_CONTENT_TYPES: - raise HTTPException(400, f'Unsupported file type {content_type!r}, must be one of {IMAGE_CONTENT_TYPES}') + return Response(f'Unsupported file type {content_type!r}, must be one of {IMAGE_CONTENT_TYPES}', 400) try: - oauth2_credentials_: dict = _json_decode(oauth2_credentials) - except Exception as e: - raise HTTPException(400, 'OAuth2 credentials must be a JSON object') from e - + oauth2_credentials_: dict = JSON_DECODE(oauth2_credentials) + except Exception: + return Response('OAuth2 credentials must be a JSON object', 400) if 'access_token' not in oauth2_credentials_: - raise HTTPException(400, 'OAuth2 credentials must contain an access_token field') + return Response('OAuth2 credentials must contain an access_token field', 400) - aed = await AEDState.get_aed_by_id(node_id) + aed = await AEDService.get_by_id(node_id) if aed is None: - raise HTTPException(404, f'Node {node_id} not found, perhaps it is not an AED?') + return Response(f'Node {node_id} not found, perhaps it is not an AED?', 404) osm = OpenStreetMap(oauth2_credentials_) osm_user = await osm.get_authorized_user() if osm_user is None: - raise HTTPException(401, 'OAuth2 credentials are invalid') - + return Response('OAuth2 credentials are invalid', 401) if osm_user_has_active_block(osm_user): - raise HTTPException(403, 'User has an active block on OpenStreetMap') - - photo_info = await PhotoState.set_photo(node_id, osm_user['id'], file) - photo_url = f'{request.base_url}api/v1/photos/view/{photo_info.id}.webp' + return Response('User has an active block on OpenStreetMap', 403) + user_id = osm_user['id'] + photo = await PhotoService.upload(node_id, user_id, file) + photo_url = f'{request.base_url}api/v1/photos/view/{photo.id}.webp' node_xml = await osm.get_node_xml(node_id) osm_change = update_node_tags_osm_change( @@ -148,7 +142,8 @@ async def upload( @router.post('/report') async def report(id: Annotated[str, Form()]): - return await PhotoReportState.report_by_photo_id(id) + await PhotoReportService.create(id) + return Response() @router.get('/report/rss.xml') @@ -158,11 +153,8 @@ async def report_rss(request: Request): fg.description('This feed contains a list of recent AED photo reports') fg.link(href=str(request.url), rel='self') - for report in await PhotoReportState.get_recent_reports(): - info = await PhotoState.get_photo_by_id(report.photo_id) - - if info is None: - continue + for report in await PhotoReportService.get_recent(): + photo = report.photo fe = fg.add_entry(order='append') fe.id(report.id) @@ -170,13 +162,13 @@ async def report_rss(request: Request): fe.content( '
'.join( ( - f'File name: {info.path.name}', - f'Node: https://osm.org/node/{info.node_id}', + f'File name: {photo.file_path.name}', + f'Node: https://osm.org/node/{photo.node_id}', ) ), type='CDATA', ) fe.link(href=f'{request.base_url}api/v1/photos/view/{report.photo_id}.webp') - fe.published(datetime.utcfromtimestamp(report.timestamp).astimezone(tz=UTC)) + fe.published(datetime.fromtimestamp(report.timestamp, UTC)) return Response(content=fg.rss_str(pretty=True), media_type='application/rss+xml') diff --git a/api/v1/tile.py b/api/v1/tile.py index 0378af9..d26f18a 100644 --- a/api/v1/tile.py +++ b/api/v1/tile.py @@ -21,11 +21,11 @@ TILE_MIN_Z, ) from middlewares.cache_middleware import make_cache_control -from models.aed import AED from models.bbox import BBox -from models.country import Country -from states.aed_state import AEDState -from states.country_state import CountryState +from models.db.aed import AED +from models.db.country import Country +from services.aed_service import AEDService +from services.country_service import CountryService from utils import abbreviate router = APIRouter() @@ -108,13 +108,13 @@ def _mvt_encode(bbox: BBox, layers: Sequence[dict]) -> bytes: @trace async def _get_tile_country(z: int, bbox: BBox) -> bytes: - countries = await CountryState.get_countries_within(bbox) + countries = await CountryService.get_intersecting(bbox) country_count_map: dict[str, str] = {} with start_span(description='Counting AEDs'): async def count_task(country: Country) -> None: - count = await AEDState.count_aeds_by_country_code(country.code) + count = await AEDService.count_by_country_code(country.code) country_count_map[country.name] = (count, abbreviate(count)) async with create_task_group() as tg: @@ -159,7 +159,7 @@ async def count_task(country: Country) -> None: @trace async def _get_tile_aed(z: int, bbox: BBox) -> bytes: group_eps = 9.6 / 2**z if z < TILE_MAX_Z else None - aeds = await AEDState.get_aeds_within_bbox(bbox.extend(0.5), group_eps) + aeds = await AEDService.get_intersecting(bbox.extend(0.5), group_eps) return _mvt_encode( bbox, diff --git a/config.py b/config.py index d1e794d..001f9c1 100644 --- a/config.py +++ b/config.py @@ -1,22 +1,19 @@ import os from datetime import timedelta +from logging.config import dictConfig -import pymongo import sentry_sdk from anyio import Path -from motor.core import AgnosticDatabase -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo import IndexModel from pyproj import Transformer -from sentry_sdk.integrations.pymongo import PyMongoIntegration NAME = 'openaedmap-backend' -VERSION = '2.8.3' +VERSION = '2.9.0' CREATED_BY = f'{NAME} {VERSION}' WEBSITE = 'https://openaedmap.org' -ENVIRONMENT = os.getenv('ENVIRONMENT', None) USER_AGENT = f'{NAME}/{VERSION} (+{WEBSITE})' +ENVIRONMENT = os.getenv('ENVIRONMENT', None) +LOG_LEVEL = 'DEBUG' if ENVIRONMENT: sentry_sdk.init( @@ -27,23 +24,20 @@ traces_sample_rate=0.2, trace_propagation_targets=None, profiles_sample_rate=0.2, - integrations=[ - PyMongoIntegration(), - ], ) -OVERPASS_API_URL = 'https://overpass-api.de/api/interpreter' -OPENSTREETMAP_API_URL = os.getenv('OPENSTREETMAP_API_URL', 'https://api.openstreetmap.org/api/0.6/') -REPLICATION_URL = 'https://planet.openstreetmap.org/replication/minute/' -COUNTRIES_GEOJSON_URL = 'https://osm-countries-geojson.monicz.dev/osm-countries-0-01.geojson.zst' +POSTGRES_LOG = os.getenv('POSTGRES_LOG', '0').strip().lower() in ('1', 'true', 'yes') +POSTGRES_URL = 'postgresql+asyncpg://postgres:postgres@127.0.0.1/postgres' DEFAULT_CACHE_MAX_AGE = timedelta(minutes=1) DEFAULT_CACHE_STALE = timedelta(minutes=5) +COUNTRY_GEOJSON_URL = 'https://osm-countries-geojson.monicz.dev/osm-countries-0-01.geojson.zst' COUNTRY_UPDATE_DELAY = timedelta(days=float(os.getenv('COUNTRY_UPDATE_DELAY', '1'))) AED_UPDATE_DELAY = timedelta(seconds=30) AED_REBUILD_THRESHOLD = timedelta(hours=1) +PLANET_REPLICA_URL = 'https://planet.openstreetmap.org/replication/minute/' PLANET_DIFF_TIMEOUT = timedelta(minutes=5) TILE_COUNTRIES_CACHE_MAX_AGE = timedelta(hours=4) @@ -54,6 +48,9 @@ TILE_MIN_Z = 3 TILE_MAX_Z = 16 +OVERPASS_API_URL = 'https://overpass-api.de/api/interpreter' +OPENSTREETMAP_API_URL = os.getenv('OPENSTREETMAP_API_URL', 'https://api.openstreetmap.org/api/0.6/') + DEFAULT_CHANGESET_TAGS = { 'comment': 'Updated AED image', 'created_by': CREATED_BY, @@ -70,53 +67,43 @@ IMAGE_CONTENT_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/webp'} IMAGE_LIMIT_PIXELS = 6 * 1000 * 1000 # 6 MP (e.g., 3000x2000) IMAGE_MAX_FILE_SIZE = 2 * 1024 * 1024 # 2 MB - -REMOTE_IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +IMAGE_REMOTE_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB DATA_DIR = Path('data') -PHOTOS_DIR = DATA_DIR / 'photos' - -MONGO_HOST = os.getenv('MONGO_HOST', '127.0.0.1') -MONGO_PORT = int(os.getenv('MONGO_PORT', '27017')) -MONGO_CLIENT = AsyncIOMotorClient(f'mongodb://{MONGO_HOST}:{MONGO_PORT}/?replicaSet=rs0') -_mongo_db: AgnosticDatabase = MONGO_CLIENT[NAME] - -STATE_COLLECTION = _mongo_db['state'] -COUNTRY_COLLECTION = _mongo_db['country'] -AED_COLLECTION = _mongo_db['aed'] -PHOTO_COLLECTION = _mongo_db['photo'] -PHOTO_REPORT_COLLECTION = _mongo_db['photo_report'] - - -# this is run by a single, primary worker on startup -async def startup_setup() -> None: - await DATA_DIR.mkdir(exist_ok=True) - await PHOTOS_DIR.mkdir(exist_ok=True) - - await COUNTRY_COLLECTION.create_indexes( - [ - IndexModel([('geometry', pymongo.GEOSPHERE)]), - ] - ) - - await AED_COLLECTION.create_indexes( - [ - IndexModel([('id', pymongo.ASCENDING)], unique=True), - IndexModel([('country_codes', pymongo.ASCENDING)]), - IndexModel([('position', pymongo.GEOSPHERE)]), - ] - ) - - await PHOTO_COLLECTION.create_indexes( - [ - IndexModel([('id', pymongo.ASCENDING)], unique=True), - IndexModel([('node_id', pymongo.ASCENDING), ('timestamp', pymongo.DESCENDING)]), - ] - ) - - await PHOTO_REPORT_COLLECTION.create_indexes( - [ - IndexModel([('photo_id', pymongo.ASCENDING)], unique=True), - IndexModel([('timestamp', pymongo.DESCENDING)]), - ] - ) +PHOTOS_DIR = Path('data/photos') + +# Logging configuration +dictConfig( + { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'default': { + '()': 'uvicorn.logging.DefaultFormatter', + 'fmt': '%(levelprefix)s | %(asctime)s | %(name)s: %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + }, + 'handlers': { + 'default': { + 'formatter': 'default', + 'class': 'logging.StreamHandler', + 'stream': 'ext://sys.stderr', + }, + }, + 'loggers': { + 'root': {'handlers': ['default'], 'level': LOG_LEVEL}, + **{ + # reduce logging verbosity of some modules + module: {'handlers': ['default'], 'level': 'INFO'} + for module in ( + 'hpack', + 'httpx', + 'httpcore', + 'multipart', + 'PIL', + ) + }, + }, + } +) diff --git a/config/alembic.ini b/config/alembic.ini new file mode 100644 index 0000000..1256214 --- /dev/null +++ b/config/alembic.ini @@ -0,0 +1,121 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic_ + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +timezone = UTC + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +hooks = ruff +ruff.type = exec +ruff.executable = ruff +ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,alembic_utils + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_alembic_utils] +level = INFO +handlers = +qualname = alembic_utils + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/config/postgres.conf b/config/postgres.conf new file mode 100644 index 0000000..e966f9a --- /dev/null +++ b/config/postgres.conf @@ -0,0 +1,65 @@ +# ( Low-End Deployment Configuration ) +# =========================== +# Targeted Specification: +# - 2 CPU Threads +# - 2GB RAM +# - 6GB SSD + +# disable listening on unix socket +# reason: unused, improved compatibility +unix_socket_directories = '' + +# adjust memory usage +shared_buffers = 512MB +effective_cache_size = 1GB + +# disable replication and reduce WAL usage +# reason: unused, reduced resource usage +wal_level = minimal +max_wal_senders = 0 + +# compress WAL logs +# reason: reduced IO usage, higher throughput +wal_compression = zstd + +# group WAL commits during high load (delay 100ms) +# reason: higher throughput +commit_delay = 100000 +commit_siblings = 3 + +# reduce checkpoint frequency +# reason: higher chance of vaccuming in-memory, reduced WAL usage +checkpoint_timeout = 30min + +# print early checkpoint warnings +# reason: detect too-frequent checkpoints +checkpoint_warning = 10min + +# adjust configuration for SSDs +# reason: improved performance on expected hardware +effective_io_concurrency = 200 +maintenance_io_concurrency = 200 +random_page_cost = 2 + +# increase logging verbosity +# reason: useful for development +log_lock_waits = on +log_temp_files = 0 # == log all temp files + +# configure autovacuum to use absolute thresholds +# reason: more frequent vacuuming, predictable behavior +autovacuum_max_workers = 2 +autovacuum_naptime = 5min +autovacuum_vacuum_scale_factor = 0.0 +autovacuum_vacuum_threshold = 100 +autovacuum_vacuum_insert_scale_factor = 0.0 +autovacuum_vacuum_insert_threshold = 100 +autovacuum_analyze_scale_factor = 0.0 +autovacuum_analyze_threshold = 100 + +# configure additional libraries +shared_preload_libraries = 'auto_explain' + +# automatically explain slow queries +# reason: useful for development +auto_explain.log_min_duration = 100ms diff --git a/config/supervisord.conf b/config/supervisord.conf new file mode 100644 index 0000000..1b41100 --- /dev/null +++ b/config/supervisord.conf @@ -0,0 +1,16 @@ +[supervisord] +logfile=data/supervisor/supervisord.log +pidfile=data/supervisor/supervisord.pid +strip_ansi=true + +[program:postgres] +command=postgres -c config_file=config/postgres.conf -D data/postgres +stopsignal=INT +stdout_logfile=data/supervisor/postgres.log +stderr_logfile=data/supervisor/postgres.log + +[program:varnish] +command=varnishd -f config/varnish.vcl -s file,data/cache/varnish.bin,2G +stopsignal=INT +stdout_logfile=data/supervisor/varnish.log +stderr_logfile=data/supervisor/varnish.log diff --git a/default.vcl b/config/varnish.vcl similarity index 98% rename from default.vcl rename to config/varnish.vcl index 19d3af2..f36ff31 100644 --- a/default.vcl +++ b/config/varnish.vcl @@ -3,7 +3,7 @@ vcl 4.1; import std; backend default { - .host = "app"; + .host = "127.0.0.1"; .port = "8000"; } diff --git a/country_code_assigner.py b/country_code_assigner.py new file mode 100644 index 0000000..1d8872f --- /dev/null +++ b/country_code_assigner.py @@ -0,0 +1,19 @@ +class CountryCodeAssigner: + __slots__ = 'used' + + def __init__(self): + self.used = set() + + def get_unique(self, tags: dict[str, str]) -> str: + for check_used in (True, False): + for code in ( + tags.get('ISO3166-2'), + tags.get('ISO3166-1'), + tags.get('ISO3166-1:alpha2'), + tags.get('ISO3166-1:alpha3'), + ): + if code and len(code) >= 2 and (not check_used or code not in self.used): + self.used.add(code) + return code + + return 'XX' diff --git a/db.py b/db.py new file mode 100644 index 0000000..44b7c16 --- /dev/null +++ b/db.py @@ -0,0 +1,42 @@ +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from config import POSTGRES_LOG, POSTGRES_URL +from utils import JSON_DECODE, JSON_ENCODE + +_db_engine = create_async_engine( + POSTGRES_URL, + echo=POSTGRES_LOG, + echo_pool=POSTGRES_LOG, + json_deserializer=JSON_DECODE, + json_serializer=lambda x: JSON_ENCODE(x).decode(), # TODO: is decode needed? + query_cache_size=128, +) + + +@asynccontextmanager +async def db_read(): + """ + Get a database session for reading. + """ + async with AsyncSession( + _db_engine, + expire_on_commit=False, + close_resets_only=False, + ) as session: + yield session + + +@asynccontextmanager +async def db_write(): + """ + Get a database session for writing, automatically committing on exit. + """ + async with AsyncSession( + _db_engine, + expire_on_commit=False, + close_resets_only=False, + ) as session: + yield session + await session.commit() diff --git a/default.nix b/default.nix index b991f35..d3d7f67 100644 --- a/default.nix +++ b/default.nix @@ -11,40 +11,73 @@ let name = "python-venv"; paths = [ (pkgs.runCommand "python-venv" { } '' - mkdir -p $out/lib + set -e + mkdir -p $out/bin $out/lib + find "${./.venv/bin}" -type f -executable -exec cp {} $out/bin \; + sed -i '1s|^#!.*/python|#!/usr/bin/env python|' $out/bin/* cp -r "${./.venv/lib/python3.12/site-packages}"/* $out/lib '') ]; + pathsToLink = [ "/bin" "/lib" ]; }; + + entrypoint = (pkgs.writeShellScriptBin "entrypoint" '' + set -ex + dev-start + + set -o allexport + source "envs/app/${envTag}.env" set + +o allexport + + exec python -m uvicorn main:app "$@" + ''); in with pkgs; dockerTools.buildLayeredImage { name = "backend"; tag = if envTag != "" then envTag else "latest"; - contents = shell.buildInputs ++ [ python-venv ]; + contents = shell.buildInputs ++ [ + dockerTools.usrBinEnv + dockerTools.binSh # initdb dependency + python-venv + ]; extraCommands = '' set -e mkdir tmp mkdir app && cd app cp "${./.}"/*.py . + cp -r "${./.}"/alembic_ . cp -r "${./.}"/api . + cp -r "${./.}"/config . + cp -r "${./.}"/envs . cp -r "${./.}"/middlewares . cp -r "${./.}"/models . - cp -r "${./.}"/states . - cp -r "${./.}"/validators . + cp -r "${./.}"/services . ''; + fakeRootCommands = '' + set -e + ${dockerTools.shadowSetup} + groupadd --system -g 999 docker + useradd --system --no-create-home -u 999 -g 999 docker + chown -R docker:docker app + ''; + + enableFakechroot = true; + config = { WorkingDir = "/app"; Env = [ "PYTHONPATH=${python-venv}/lib" "PYTHONUNBUFFERED=1" "PYTHONDONTWRITEBYTECODE=1" + "TZ=UTC" ]; Volumes = { "/app/data/photos" = { }; }; - Entrypoint = [ "python" "-m" "uvicorn" "main:app" ]; + Entrypoint = [ "${entrypoint}/bin/entrypoint" ]; + User = "docker:docker"; }; } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 4091fa7..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: "3" - -services: - db: - image: docker.io/library/mongo - command: ["mongod", "--bind_ip_all", "--setParameter", "transactionLifetimeLimitSeconds=90", "--replSet", "rs0"] - - ports: - - 127.0.0.1:27017:27017 - - volumes: - - ./data/db:/data/db - - db-setup: - image: docker.io/library/mongo - entrypoint: ["/bin/sh", "/mongo-init-replica.sh"] - command: ["127.0.0.1:27017"] - - depends_on: - - db - - volumes: - - ./scripts/mongo-init-replica.sh:/mongo-init-replica.sh:ro diff --git a/docker-compose.yml b/docker-compose.yml index 46d3570..d4a53ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,11 @@ version: "3" services: - db: - image: docker.io/library/mongo - restart: unless-stopped - command: [ - "mongod", - "--bind_ip_all", - "--setParameter", - "transactionLifetimeLimitSeconds=90", - "--wiredTigerCacheSizeGB", - "0.4", # ~400MB - "--replSet", - "rs0", - ] - - volumes: - - ./data/db:/data/db - - db-setup: - image: docker.io/library/mongo - entrypoint: ["/bin/sh", "/mongo-init-replica.sh"] - command: ["db:27017"] - - depends_on: - - db - - volumes: - - ./scripts/mongo-init-replica.sh:/mongo-init-replica.sh:ro - app: - image: backend:${TAG:-latest} + image: backend:${TAG} restart: unless-stopped command: [ - "--host", - "0.0.0.0", "--workers", "${WORKERS:-1}", "--timeout-keep-alive", @@ -47,30 +17,10 @@ services: "*", ] - depends_on: - - db - - env_file: - - envs/app/${TAG:-dev}.env - - environment: - MONGO_HOST: db - - volumes: - - /mnt/data/${TAG:-dev}/photos:/app/data/photos - - cache: - image: docker.io/library/varnish:alpine - restart: unless-stopped - user: root - command: ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-s", "file,/var/lib/varnish/varnish_storage.bin,2G"] - - depends_on: - - app - ports: - ${LISTEN:-80}:80 volumes: - - ./default.vcl:/etc/varnish/default.vcl:ro - - ./data/cache:/var/lib/varnish + - ./data/db:/app/data/db + - ./data/cache:/app/data/cache + - /mnt/data/${TAG}/photos:/app/data/photos diff --git a/json_response.py b/json_response.py index 35cd4ee..79dd690 100644 --- a/json_response.py +++ b/json_response.py @@ -1,9 +1,8 @@ from typing import override from fastapi.responses import JSONResponse -from msgspec.json import Encoder -_encode = Encoder(decimal_format='number').encode +from utils import JSON_ENCODE class CustomJSONResponse(JSONResponse): @@ -11,4 +10,4 @@ class CustomJSONResponse(JSONResponse): @override def render(self, content) -> bytes: - return _encode(content) + return JSON_ENCODE(content) diff --git a/main.py b/main.py index 2ab386a..d0185a5 100644 --- a/main.py +++ b/main.py @@ -8,34 +8,32 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware -from config import DEFAULT_CACHE_MAX_AGE, DEFAULT_CACHE_STALE, startup_setup +from config import DEFAULT_CACHE_MAX_AGE, DEFAULT_CACHE_STALE from json_response import CustomJSONResponse from middlewares.cache_middleware import CacheMiddleware from middlewares.profiler_middleware import ProfilerMiddleware from middlewares.version_middleware import VersionMiddleware -from states.aed_state import AEDState -from states.country_state import CountryState -from states.worker_state import WorkerState, WorkerStateEnum +from services.aed_service import AEDService +from services.country_service import CountryService +from services.worker_service import WorkerService @asynccontextmanager async def lifespan(_): - worker_state = WorkerState() - await worker_state.ainit() + worker_state = await WorkerService.init() if worker_state.is_primary: - await startup_setup() async with create_task_group() as tg: - await tg.start(CountryState.update_db_task) - await tg.start(AEDState.update_db_task) + await tg.start(CountryService.update_db_task) + await tg.start(AEDService.update_db_task) - await worker_state.set_state(WorkerStateEnum.RUNNING) + await worker_state.set_state('running') yield # on shutdown, always abort the tasks tg.cancel_scope.cancel() else: - await worker_state.wait_for_state(WorkerStateEnum.RUNNING) + await worker_state.wait_for_state('running') yield diff --git a/models/aed.py b/models/aed.py deleted file mode 100644 index b24b19b..0000000 --- a/models/aed.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Annotated - -from pydantic import BaseModel, ConfigDict -from shapely import Point - -from validators.geometry import GeometrySerializer, GeometryValidator - - -class AED(BaseModel): - model_config = ConfigDict(frozen=True, strict=True) - - id: int - position: Annotated[Point, GeometryValidator, GeometrySerializer] - country_codes: list[str] | None - tags: dict[str, str] - version: int - - @property - def access(self) -> str: - return self.tags.get('access', '') diff --git a/models/bbox.py b/models/bbox.py index 0cefe4f..c99a293 100644 --- a/models/bbox.py +++ b/models/bbox.py @@ -30,7 +30,6 @@ def from_tuple(cls, bbox: tuple[float, float, float, float]) -> Self: def to_tuple(self) -> tuple[float, float, float, float]: p1_x, p1_y = get_coordinates(self.p1)[0] p2_x, p2_y = get_coordinates(self.p2)[0] - return (p1_x, p1_y, p2_x, p2_y) def to_polygon(self, *, nodes_per_edge: int = 2) -> Polygon: @@ -57,7 +56,6 @@ def to_polygon(self, *, nodes_per_edge: int = 2) -> Polygon: right_edge = np.column_stack((np.full(nodes_per_edge - 2, p2_x), y_vals[1:-1])) all_coords = np.concatenate((bottom_edge, right_edge, top_edge[::-1], left_edge[::-1])) - return Polygon(all_coords) def correct_for_dateline(self) -> tuple[Self, ...]: diff --git a/models/country.py b/models/country.py deleted file mode 100644 index ccad7a1..0000000 --- a/models/country.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Annotated - -from pydantic import BaseModel, ConfigDict -from shapely import Point -from shapely.geometry.base import BaseGeometry - -from validators.geometry import GeometrySerializer, GeometryValidator - - -class Country(BaseModel): - model_config = ConfigDict(frozen=True, strict=True) - - names: dict[str, str] - code: str - geometry: Annotated[BaseGeometry, GeometryValidator, GeometrySerializer] - label_position: Annotated[Point, GeometryValidator, GeometrySerializer] - - @property - def name(self) -> str: - return self.names['default'] - - def get_name(self, lang: str) -> str: - return self.names.get(lang.upper(), self.name) diff --git a/models/db/__init__.py b/models/db/__init__.py new file mode 100644 index 0000000..43f3d08 --- /dev/null +++ b/models/db/__init__.py @@ -0,0 +1,5 @@ +import pathlib + +# import all files in this directory +modules = pathlib.Path(__file__).parent.glob('*.py') +__all__ = tuple(f.stem for f in modules if f.is_file() and not f.name.startswith('_')) diff --git a/models/db/aed.py b/models/db/aed.py new file mode 100644 index 0000000..40eff9b --- /dev/null +++ b/models/db/aed.py @@ -0,0 +1,36 @@ +from shapely import Point +from sqlalchemy import ARRAY, BigInteger, Index, Unicode +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from models.db.base import Base +from models.geometry import PointType + + +class AED(Base): + __tablename__ = 'aed' + + id: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + primary_key=True, + ) + + version: Mapped[int] = mapped_column(BigInteger, nullable=False) + tags: Mapped[dict[str, str]] = mapped_column(JSONB, nullable=False) + position: Mapped[Point] = mapped_column(PointType, nullable=False) + + country_codes: Mapped[list[str] | None] = mapped_column( + ARRAY(Unicode(8), dimensions=1), + nullable=True, + default=None, + ) + + __table_args__ = ( + Index('aed_position_idx', position, postgresql_using='gist'), + Index('aed_country_codes_idx', country_codes, postgresql_using='gin'), + ) + + @property + def access(self) -> str: + return self.tags.get('access', '') diff --git a/models/db/base.py b/models/db/base.py new file mode 100644 index 0000000..57c6bc2 --- /dev/null +++ b/models/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass + + +class Base(MappedAsDataclass, DeclarativeBase, kw_only=True): + pass diff --git a/models/db/country.py b/models/db/country.py new file mode 100644 index 0000000..b634806 --- /dev/null +++ b/models/db/country.py @@ -0,0 +1,31 @@ +from asyncpg import Polygon +from shapely import MultiPolygon, Point +from sqlalchemy import Index, Unicode +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from models.db.base import Base +from models.geometry import PointType, PolygonType + + +class Country(Base): + __tablename__ = 'country' + + code: Mapped[str] = mapped_column( + Unicode(8), + nullable=False, + primary_key=True, + ) + + names: Mapped[dict[str, str]] = mapped_column(JSONB, nullable=False) + geometry: Mapped[Polygon | MultiPolygon] = mapped_column(PolygonType, nullable=False) + label_position: Mapped[Point] = mapped_column(PointType, nullable=False) + + __table_args__ = (Index('country_geometry_idx', geometry, postgresql_using='gist'),) + + @property + def name(self) -> str: + return self.names['default'] + + def get_name(self, lang: str) -> str: + return self.names.get(lang.upper(), self.name) diff --git a/models/db/created_at_mixin.py b/models/db/created_at_mixin.py new file mode 100644 index 0000000..019aa94 --- /dev/null +++ b/models/db/created_at_mixin.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + + +class CreatedAtMixin: + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(True), + init=False, + nullable=False, + server_default=func.statement_timestamp(), + ) diff --git a/models/db/photo.py b/models/db/photo.py new file mode 100644 index 0000000..1b997f7 --- /dev/null +++ b/models/db/photo.py @@ -0,0 +1,28 @@ +import secrets + +from anyio import Path +from sqlalchemy import BigInteger, Unicode +from sqlalchemy.orm import Mapped, mapped_column + +from config import PHOTOS_DIR +from models.db.base import Base +from models.db.created_at_mixin import CreatedAtMixin + + +class Photo(Base, CreatedAtMixin): + __tablename__ = 'photo' + + id: Mapped[str] = mapped_column( + Unicode(32), + init=False, + nullable=False, + primary_key=True, + default=lambda: secrets.token_urlsafe(16), + ) + + node_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + + @property + def file_path(self) -> Path: + return PHOTOS_DIR / f'{self.user_id}_{self.node_id}_{self.id}.webp' diff --git a/models/db/photo_report.py b/models/db/photo_report.py new file mode 100644 index 0000000..f8872fb --- /dev/null +++ b/models/db/photo_report.py @@ -0,0 +1,28 @@ +import secrets + +from sqlalchemy import ForeignKey, Index, Unicode +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.db.base import Base +from models.db.created_at_mixin import CreatedAtMixin +from models.db.photo import Photo + + +class PhotoReport(Base, CreatedAtMixin): + __tablename__ = 'photo_report' + + id: Mapped[str] = mapped_column( + Unicode(32), + init=False, + nullable=False, + primary_key=True, + default=lambda: secrets.token_urlsafe(16), + ) + + photo_id: Mapped[str] = mapped_column(ForeignKey(Photo.id), nullable=False) + photo: Mapped[Photo] = relationship(init=False, lazy='joined', innerjoin=True) + + __table_args__ = ( + Index('photo_report_photo_id_idx', photo_id, unique=True), + Index('photo_report_created_at_idx', 'created_at'), + ) diff --git a/models/db/state.py b/models/db/state.py new file mode 100644 index 0000000..3f4bb63 --- /dev/null +++ b/models/db/state.py @@ -0,0 +1,17 @@ +from sqlalchemy import Unicode +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from models.db.base import Base + + +class State(Base): + __tablename__ = 'state' + + key: Mapped[str] = mapped_column( + Unicode, + nullable=False, + primary_key=True, + ) + + data: Mapped[dict] = mapped_column(JSONB, nullable=False) diff --git a/models/geometry.py b/models/geometry.py new file mode 100644 index 0000000..67083ec --- /dev/null +++ b/models/geometry.py @@ -0,0 +1,67 @@ +from shapely import MultiPolygon, Point, Polygon, from_wkb, get_coordinates +from sqlalchemy import BindParameter +from sqlalchemy.sql import func +from sqlalchemy.types import UserDefinedType + + +class PointType(UserDefinedType): + cache_ok = True + + def get_col_spec(self, **kw): + return 'geometry(Point, 4326)' + + def bind_expression(self, bindvalue: BindParameter): + return func.ST_GeomFromText(bindvalue, 4326, type_=self) + + def bind_processor(self, dialect): + def process(value: Point | None): + if value is None: + return None + + x, y = get_coordinates(value)[0] + return f'SRID=4326;POINT({x} {y})' # WKT + + return process + + def column_expression(self, col): + return func.ST_AsBinary(col, type_=self) + + def result_processor(self, dialect, coltype): + def process(value: bytes | None): + if value is None: + return None + + return from_wkb(value) + + return process + + +class PolygonType(UserDefinedType): + cache_ok = True + + def get_col_spec(self, **kw): + return 'geometry(Geometry, 4326)' + + def bind_expression(self, bindvalue: BindParameter): + return func.ST_GeomFromText(bindvalue, 4326, type_=self) + + def bind_processor(self, dialect): + def process(value: Polygon | MultiPolygon | None): + if value is None: + return None + + return 'SRID=4326;' + value.wkt + + return process + + def column_expression(self, col): + return func.ST_AsBinary(col, type_=self) + + def result_processor(self, dialect, coltype): + def process(value: bytes | None): + if value is None: + return None + + return from_wkb(value) + + return process diff --git a/models/osm_country.py b/models/osm_country.py index cb60c9e..5e2bb8d 100644 --- a/models/osm_country.py +++ b/models/osm_country.py @@ -6,6 +6,6 @@ class OSMCountry(NamedTuple): tags: dict[str, str] - timestamp: float - representative_point: Point geometry: BaseGeometry + representative_point: Point + timestamp: float diff --git a/models/photo_info.py b/models/photo_info.py deleted file mode 100644 index 6c967cc..0000000 --- a/models/photo_info.py +++ /dev/null @@ -1,17 +0,0 @@ -from anyio import Path -from pydantic import BaseModel, ConfigDict - -from config import PHOTOS_DIR - - -class PhotoInfo(BaseModel): - model_config = ConfigDict(frozen=True, strict=True) - - id: str - node_id: str - user_id: str - timestamp: float - - @property - def path(self) -> Path: - return PHOTOS_DIR / f'{self.user_id}_{self.node_id}_{self.id}.webp' diff --git a/models/photo_report.py b/models/photo_report.py deleted file mode 100644 index cfe05b0..0000000 --- a/models/photo_report.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class PhotoReport(BaseModel): - model_config = ConfigDict(frozen=True, strict=True) - - id: str - photo_id: str - timestamp: float diff --git a/openstreetmap.py b/openstreetmap.py index 6b963db..898eb60 100644 --- a/openstreetmap.py +++ b/openstreetmap.py @@ -1,4 +1,4 @@ -from datetime import timedelta +import logging import xmltodict from authlib.integrations.httpx_client import OAuth2Auth @@ -17,7 +17,7 @@ class OpenStreetMap: def __init__(self, oauth2_credentials: dict): self._http = get_http_client(OPENSTREETMAP_API_URL, auth=OAuth2Auth(oauth2_credentials)) - @retry_exponential(timedelta(seconds=10)) + @retry_exponential(10) @trace async def get_authorized_user(self) -> dict | None: r = await self._http.get('/user/details.json') @@ -29,7 +29,7 @@ async def get_authorized_user(self) -> dict | None: return r.json()['user'] - @retry_exponential(timedelta(seconds=10)) + @retry_exponential(10) @trace async def get_node_xml(self, node_id: int) -> dict | None: r = await self._http.get(f'/node/{node_id}') @@ -73,7 +73,8 @@ async def upload_osm_change(self, osm_change: str) -> str: changeset_id = r.text osm_change = osm_change.replace(CHANGESET_ID_PLACEHOLDER, changeset_id) - print(f'🌐 Changeset: https://www.openstreetmap.org/changeset/{changeset_id}') + logging.info('Uploading changeset %s', changeset_id) + logging.info('https://www.openstreetmap.org/changeset/%s', changeset_id) upload_resp = await self._http.post( f'/changeset/{changeset_id}/upload', diff --git a/osm_countries.py b/osm_countries.py index 7001d64..45c27ec 100644 --- a/osm_countries.py +++ b/osm_countries.py @@ -1,27 +1,25 @@ from collections.abc import Sequence -from msgspec.json import Decoder from sentry_sdk import trace from shapely.geometry import shape from zstandard import ZstdDecompressor -from config import COUNTRIES_GEOJSON_URL +from config import COUNTRY_GEOJSON_URL from models.osm_country import OSMCountry -from utils import get_http_client +from utils import JSON_DECODE, get_http_client _zstd_decompress = ZstdDecompressor().decompress -_json_decode = Decoder().decode @trace async def get_osm_countries() -> Sequence[OSMCountry]: async with get_http_client() as http: - r = await http.get(COUNTRIES_GEOJSON_URL) + r = await http.get(COUNTRY_GEOJSON_URL) r.raise_for_status() buffer = r.read() buffer = _zstd_decompress(buffer) - data: dict = _json_decode(buffer) + data: dict = JSON_DECODE(buffer) result = [] @@ -32,10 +30,10 @@ async def get_osm_countries() -> Sequence[OSMCountry]: result.append( OSMCountry( tags=props['tags'], - timestamp=props['timestamp'], - representative_point=shape(props['representative_point']), geometry=shape(geometry), + representative_point=shape(props['representative_point']), + timestamp=props['timestamp'], ) ) - return tuple(result) + return result diff --git a/planet_diffs.py b/planet_diffs.py index 8d7644a..9b0df60 100644 --- a/planet_diffs.py +++ b/planet_diffs.py @@ -9,51 +9,15 @@ from httpx import AsyncClient from sentry_sdk import start_span, trace -from config import AED_REBUILD_THRESHOLD, PLANET_DIFF_TIMEOUT, REPLICATION_URL +from config import AED_REBUILD_THRESHOLD, PLANET_DIFF_TIMEOUT, PLANET_REPLICA_URL from utils import get_http_client, retry_exponential from xmltodict_postprocessor import xmltodict_postprocessor -def _format_sequence_number(sequence_number: int) -> str: - result = f'{sequence_number:09d}' - result = '/'.join(result[i : i + 3] for i in range(0, 9, 3)) - return result - - -def _format_actions(xml: str) -> str: - # -> - # -> - # etc. - xml = re.sub(r'<(create|modify|delete)>', r'', xml) - xml = re.sub(r'', r'', xml) - return xml - - -@retry_exponential(AED_REBUILD_THRESHOLD) -@trace -async def _get_state(http: AsyncClient, sequence_number: int | None) -> tuple[int, float]: - if sequence_number is None: - r = await http.get('state.txt') - else: - r = await http.get(f'{_format_sequence_number(sequence_number)}.state.txt') - - r.raise_for_status() - - text = r.text - text = text.replace('\\:', ':') - - sequence_number = int(re.search(r'sequenceNumber=(\d+)', text).group(1)) - sequence_date_str = re.search(r'timestamp=(\S+)', text).group(1) - sequence_date = datetime.strptime(sequence_date_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) - sequence_timestamp = sequence_date.timestamp() - - return sequence_number, sequence_timestamp - - @trace async def get_planet_diffs(last_update: float) -> tuple[Sequence[dict], float]: with fail_after(PLANET_DIFF_TIMEOUT.total_seconds()): - async with get_http_client(REPLICATION_URL) as http: + async with get_http_client(PLANET_REPLICA_URL) as http: sequence_numbers = [] sequence_timestamps = [] @@ -108,3 +72,39 @@ async def _get_planet_diff(sequence_number: int) -> None: data = tuple(chain.from_iterable(data for _, data in result)) data_timestamp = sequence_timestamps[0] return data, data_timestamp + + +@retry_exponential(AED_REBUILD_THRESHOLD) +@trace +async def _get_state(http: AsyncClient, sequence_number: int | None) -> tuple[int, float]: + if sequence_number is None: + r = await http.get('state.txt') + else: + r = await http.get(f'{_format_sequence_number(sequence_number)}.state.txt') + + r.raise_for_status() + + text = r.text + text = text.replace('\\:', ':') + + sequence_number = int(re.search(r'sequenceNumber=(\d+)', text).group(1)) + sequence_date_str = re.search(r'timestamp=(\S+)', text).group(1) + sequence_date = datetime.strptime(sequence_date_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) + sequence_timestamp = sequence_date.timestamp() + + return sequence_number, sequence_timestamp + + +def _format_sequence_number(sequence_number: int) -> str: + result = f'{sequence_number:09d}' + result = '/'.join(result[i : i + 3] for i in range(0, 9, 3)) + return result + + +def _format_actions(xml: str) -> str: + # -> + # -> + # etc. + xml = re.sub(r'<(create|modify|delete)>', r'', xml) + xml = re.sub(r'', r'', xml) + return xml diff --git a/poetry.lock b/poetry.lock index 9984130..a1f16b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,23 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "alembic" +version = "1.13.1" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"}, + {file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] [[package]] name = "annotated-types" @@ -45,6 +64,60 @@ files = [ [package.dependencies] cachetools = ">=5.2.0,<6.0.0" +[[package]] +name = "asyncpg" +version = "0.29.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169"}, + {file = "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb"}, + {file = "asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449"}, + {file = "asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b"}, + {file = "asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675"}, + {file = "asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175"}, + {file = "asyncpg-0.29.0-cp312-cp312-win32.whl", hash = "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02"}, + {file = "asyncpg-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9"}, + {file = "asyncpg-0.29.0-cp38-cp38-win32.whl", hash = "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408"}, + {file = "asyncpg-0.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c"}, + {file = "asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2"}, + {file = "asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8"}, + {file = "asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e"}, +] + +[package.extras] +docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"] + [[package]] name = "authlib" version = "1.3.0" @@ -477,40 +550,20 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -[[package]] -name = "dnspython" -version = "2.6.1" -description = "DNS toolkit" -optional = false -python-versions = ">=3.8" -files = [ - {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, - {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=41)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=0.9.25)"] -idna = ["idna (>=3.6)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - [[package]] name = "fastapi" -version = "0.110.0" +version = "0.110.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.110.0-py3-none-any.whl", hash = "sha256:87a1f6fb632a218222c5984be540055346a8f5d8a68e8f6fb647b1dc9934de4b"}, - {file = "fastapi-0.110.0.tar.gz", hash = "sha256:266775f0dcc95af9d3ef39bad55cff525329a931d5fd51930aadd4f428bf7ff3"}, + {file = "fastapi-0.110.1-py3-none-any.whl", hash = "sha256:5df913203c482f820d31f48e635e022f8cbfe7350e4830ef05a3163925b1addc"}, + {file = "fastapi-0.110.1.tar.gz", hash = "sha256:6feac43ec359dfe4f45b2c18ec8c94edb8dc2dfc461d417d9e626590c071baad"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.36.3,<0.37.0" +starlette = ">=0.37.2,<0.38.0" typing-extensions = ">=4.8.0" [package.extras] @@ -530,6 +583,77 @@ files = [ lxml = "*" python-dateutil = "*" +[[package]] +name = "greenlet" +version = "3.0.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, + {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, + {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, + {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, + {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, + {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, + {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, + {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, + {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, + {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, + {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, + {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, + {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, + {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, + {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, + {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + [[package]] name = "h11" version = "0.14.0" @@ -569,13 +693,13 @@ files = [ [[package]] name = "httpcore" -version = "1.0.4" +version = "1.0.5" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"}, - {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"}, + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, ] [package.dependencies] @@ -586,7 +710,7 @@ h11 = ">=0.13,<0.15" asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.25.0)"] +trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] name = "httptools" @@ -715,92 +839,151 @@ files = [ [[package]] name = "lxml" -version = "5.1.0" +version = "5.2.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" files = [ - {file = "lxml-5.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9d3c0f8567ffe7502d969c2c1b809892dc793b5d0665f602aad19895f8d508da"}, - {file = "lxml-5.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5fcfbebdb0c5d8d18b84118842f31965d59ee3e66996ac842e21f957eb76138c"}, - {file = "lxml-5.1.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f37c6d7106a9d6f0708d4e164b707037b7380fcd0b04c5bd9cae1fb46a856fb"}, - {file = "lxml-5.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2befa20a13f1a75c751f47e00929fb3433d67eb9923c2c0b364de449121f447c"}, - {file = "lxml-5.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22b7ee4c35f374e2c20337a95502057964d7e35b996b1c667b5c65c567d2252a"}, - {file = "lxml-5.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bf8443781533b8d37b295016a4b53c1494fa9a03573c09ca5104550c138d5c05"}, - {file = "lxml-5.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:82bddf0e72cb2af3cbba7cec1d2fd11fda0de6be8f4492223d4a268713ef2147"}, - {file = "lxml-5.1.0-cp310-cp310-win32.whl", hash = "sha256:b66aa6357b265670bb574f050ffceefb98549c721cf28351b748be1ef9577d93"}, - {file = "lxml-5.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:4946e7f59b7b6a9e27bef34422f645e9a368cb2be11bf1ef3cafc39a1f6ba68d"}, - {file = "lxml-5.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed8c3d2cd329bf779b7ed38db176738f3f8be637bb395ce9629fc76f78afe3d4"}, - {file = "lxml-5.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:436a943c2900bb98123b06437cdd30580a61340fbdb7b28aaf345a459c19046a"}, - {file = "lxml-5.1.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acb6b2f96f60f70e7f34efe0c3ea34ca63f19ca63ce90019c6cbca6b676e81fa"}, - {file = "lxml-5.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af8920ce4a55ff41167ddbc20077f5698c2e710ad3353d32a07d3264f3a2021e"}, - {file = "lxml-5.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cfced4a069003d8913408e10ca8ed092c49a7f6cefee9bb74b6b3e860683b45"}, - {file = "lxml-5.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9e5ac3437746189a9b4121db2a7b86056ac8786b12e88838696899328fc44bb2"}, - {file = "lxml-5.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f4c9bda132ad108b387c33fabfea47866af87f4ea6ffb79418004f0521e63204"}, - {file = "lxml-5.1.0-cp311-cp311-win32.whl", hash = "sha256:bc64d1b1dab08f679fb89c368f4c05693f58a9faf744c4d390d7ed1d8223869b"}, - {file = "lxml-5.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a5ab722ae5a873d8dcee1f5f45ddd93c34210aed44ff2dc643b5025981908cda"}, - {file = "lxml-5.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6f11b77ec0979f7e4dc5ae081325a2946f1fe424148d3945f943ceaede98adb8"}, - {file = "lxml-5.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a36c506e5f8aeb40680491d39ed94670487ce6614b9d27cabe45d94cd5d63e1e"}, - {file = "lxml-5.1.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f643ffd2669ffd4b5a3e9b41c909b72b2a1d5e4915da90a77e119b8d48ce867a"}, - {file = "lxml-5.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16dd953fb719f0ffc5bc067428fc9e88f599e15723a85618c45847c96f11f431"}, - {file = "lxml-5.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16018f7099245157564d7148165132c70adb272fb5a17c048ba70d9cc542a1a1"}, - {file = "lxml-5.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:82cd34f1081ae4ea2ede3d52f71b7be313756e99b4b5f829f89b12da552d3aa3"}, - {file = "lxml-5.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:19a1bc898ae9f06bccb7c3e1dfd73897ecbbd2c96afe9095a6026016e5ca97b8"}, - {file = "lxml-5.1.0-cp312-cp312-win32.whl", hash = "sha256:13521a321a25c641b9ea127ef478b580b5ec82aa2e9fc076c86169d161798b01"}, - {file = "lxml-5.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:1ad17c20e3666c035db502c78b86e58ff6b5991906e55bdbef94977700c72623"}, - {file = "lxml-5.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:24ef5a4631c0b6cceaf2dbca21687e29725b7c4e171f33a8f8ce23c12558ded1"}, - {file = "lxml-5.1.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d2900b7f5318bc7ad8631d3d40190b95ef2aa8cc59473b73b294e4a55e9f30f"}, - {file = "lxml-5.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:601f4a75797d7a770daed8b42b97cd1bb1ba18bd51a9382077a6a247a12aa38d"}, - {file = "lxml-5.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4b68c961b5cc402cbd99cca5eb2547e46ce77260eb705f4d117fd9c3f932b95"}, - {file = "lxml-5.1.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:afd825e30f8d1f521713a5669b63657bcfe5980a916c95855060048b88e1adb7"}, - {file = "lxml-5.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:262bc5f512a66b527d026518507e78c2f9c2bd9eb5c8aeeb9f0eb43fcb69dc67"}, - {file = "lxml-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:e856c1c7255c739434489ec9c8aa9cdf5179785d10ff20add308b5d673bed5cd"}, - {file = "lxml-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c7257171bb8d4432fe9d6fdde4d55fdbe663a63636a17f7f9aaba9bcb3153ad7"}, - {file = "lxml-5.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b9e240ae0ba96477682aa87899d94ddec1cc7926f9df29b1dd57b39e797d5ab5"}, - {file = "lxml-5.1.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a96f02ba1bcd330807fc060ed91d1f7a20853da6dd449e5da4b09bfcc08fdcf5"}, - {file = "lxml-5.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3898ae2b58eeafedfe99e542a17859017d72d7f6a63de0f04f99c2cb125936"}, - {file = "lxml-5.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61c5a7edbd7c695e54fca029ceb351fc45cd8860119a0f83e48be44e1c464862"}, - {file = "lxml-5.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3aeca824b38ca78d9ee2ab82bd9883083d0492d9d17df065ba3b94e88e4d7ee6"}, - {file = "lxml-5.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8f52fe6859b9db71ee609b0c0a70fea5f1e71c3462ecf144ca800d3f434f0764"}, - {file = "lxml-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:d42e3a3fc18acc88b838efded0e6ec3edf3e328a58c68fbd36a7263a874906c8"}, - {file = "lxml-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:eac68f96539b32fce2c9b47eb7c25bb2582bdaf1bbb360d25f564ee9e04c542b"}, - {file = "lxml-5.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c26aab6ea9c54d3bed716b8851c8bfc40cb249b8e9880e250d1eddde9f709bf5"}, - {file = "lxml-5.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cfbac9f6149174f76df7e08c2e28b19d74aed90cad60383ad8671d3af7d0502f"}, - {file = "lxml-5.1.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:342e95bddec3a698ac24378d61996b3ee5ba9acfeb253986002ac53c9a5f6f84"}, - {file = "lxml-5.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725e171e0b99a66ec8605ac77fa12239dbe061482ac854d25720e2294652eeaa"}, - {file = "lxml-5.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d184e0d5c918cff04cdde9dbdf9600e960161d773666958c9d7b565ccc60c45"}, - {file = "lxml-5.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:98f3f020a2b736566c707c8e034945c02aa94e124c24f77ca097c446f81b01f1"}, - {file = "lxml-5.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d48fc57e7c1e3df57be5ae8614bab6d4e7b60f65c5457915c26892c41afc59e"}, - {file = "lxml-5.1.0-cp38-cp38-win32.whl", hash = "sha256:7ec465e6549ed97e9f1e5ed51c657c9ede767bc1c11552f7f4d022c4df4a977a"}, - {file = "lxml-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:b21b4031b53d25b0858d4e124f2f9131ffc1530431c6d1321805c90da78388d1"}, - {file = "lxml-5.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6a2a2c724d97c1eb8cf966b16ca2915566a4904b9aad2ed9a09c748ffe14f969"}, - {file = "lxml-5.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:843b9c835580d52828d8f69ea4302537337a21e6b4f1ec711a52241ba4a824f3"}, - {file = "lxml-5.1.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b99f564659cfa704a2dd82d0684207b1aadf7d02d33e54845f9fc78e06b7581"}, - {file = "lxml-5.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8b0c78e7aac24979ef09b7f50da871c2de2def043d468c4b41f512d831e912"}, - {file = "lxml-5.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bcf86dfc8ff3e992fed847c077bd875d9e0ba2fa25d859c3a0f0f76f07f0c8d"}, - {file = "lxml-5.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:49a9b4af45e8b925e1cd6f3b15bbba2c81e7dba6dce170c677c9cda547411e14"}, - {file = "lxml-5.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:280f3edf15c2a967d923bcfb1f8f15337ad36f93525828b40a0f9d6c2ad24890"}, - {file = "lxml-5.1.0-cp39-cp39-win32.whl", hash = "sha256:ed7326563024b6e91fef6b6c7a1a2ff0a71b97793ac33dbbcf38f6005e51ff6e"}, - {file = "lxml-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:8d7b4beebb178e9183138f552238f7e6613162a42164233e2bda00cb3afac58f"}, - {file = "lxml-5.1.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9bd0ae7cc2b85320abd5e0abad5ccee5564ed5f0cc90245d2f9a8ef330a8deae"}, - {file = "lxml-5.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c1d679df4361408b628f42b26a5d62bd3e9ba7f0c0e7969f925021554755aa"}, - {file = "lxml-5.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2ad3a8ce9e8a767131061a22cd28fdffa3cd2dc193f399ff7b81777f3520e372"}, - {file = "lxml-5.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:304128394c9c22b6569eba2a6d98392b56fbdfbad58f83ea702530be80d0f9df"}, - {file = "lxml-5.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d74fcaf87132ffc0447b3c685a9f862ffb5b43e70ea6beec2fb8057d5d2a1fea"}, - {file = "lxml-5.1.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:8cf5877f7ed384dabfdcc37922c3191bf27e55b498fecece9fd5c2c7aaa34c33"}, - {file = "lxml-5.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:877efb968c3d7eb2dad540b6cabf2f1d3c0fbf4b2d309a3c141f79c7e0061324"}, - {file = "lxml-5.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f14a4fb1c1c402a22e6a341a24c1341b4a3def81b41cd354386dcb795f83897"}, - {file = "lxml-5.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:25663d6e99659544ee8fe1b89b1a8c0aaa5e34b103fab124b17fa958c4a324a6"}, - {file = "lxml-5.1.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8b9f19df998761babaa7f09e6bc169294eefafd6149aaa272081cbddc7ba4ca3"}, - {file = "lxml-5.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e53d7e6a98b64fe54775d23a7c669763451340c3d44ad5e3a3b48a1efbdc96f"}, - {file = "lxml-5.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c3cd1fc1dc7c376c54440aeaaa0dcc803d2126732ff5c6b68ccd619f2e64be4f"}, - {file = "lxml-5.1.0.tar.gz", hash = "sha256:3eea6ed6e6c918e468e693c41ef07f3c3acc310b70ddd9cc72d9ef84bc9564ca"}, + {file = "lxml-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c54f8d6160080831a76780d850302fdeb0e8d0806f661777b0714dfb55d9a08a"}, + {file = "lxml-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e95ae029396382a0d2e8174e4077f96befcd4a2184678db363ddc074eb4d3b2"}, + {file = "lxml-5.2.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5810fa80e64a0c689262a71af999c5735f48c0da0affcbc9041d1ef5ef3920be"}, + {file = "lxml-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae69524fd6a68b288574013f8fadac23cacf089c75cd3fc5b216277a445eb736"}, + {file = "lxml-5.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fadda215e32fe375d65e560b7f7e2a37c7f9c4ecee5315bb1225ca6ac9bf5838"}, + {file = "lxml-5.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f1f164e4cc6bc646b1fc86664c3543bf4a941d45235797279b120dc740ee7af5"}, + {file = "lxml-5.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3603a8a41097daf7672cae22cc4a860ab9ea5597f1c5371cb21beca3398b8d6a"}, + {file = "lxml-5.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3b4bb89a785f4fd60e05f3c3a526c07d0d68e3536f17f169ca13bf5b5dd75a5"}, + {file = "lxml-5.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1effc10bf782f0696e76ecfeba0720ea02c0c31d5bffb7b29ba10debd57d1c3d"}, + {file = "lxml-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b03531f6cd6ce4b511dcece060ca20aa5412f8db449274b44f4003f282e6272f"}, + {file = "lxml-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fac15090bb966719df06f0c4f8139783746d1e60e71016d8a65db2031ca41b8"}, + {file = "lxml-5.2.0-cp310-cp310-win32.whl", hash = "sha256:92bb37c96215c4b2eb26f3c791c0bf02c64dd251effa532b43ca5049000c4478"}, + {file = "lxml-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:b0181c22fdb89cc19e70240a850e5480817c3e815b1eceb171b3d7a3aa3e596a"}, + {file = "lxml-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ada8ce9e6e1d126ef60d215baaa0c81381ba5841c25f1d00a71cdafdc038bd27"}, + {file = "lxml-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cefb133c859f06dab2ae63885d9f405000c4031ec516e0ed4f9d779f690d8e3"}, + {file = "lxml-5.2.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ede2a7a86a977b0c741654efaeca0af7860a9b1ae39f9268f0936246a977ee0"}, + {file = "lxml-5.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46df6f0b1a0cda39d12c5c4615a7d92f40342deb8001c7b434d7c8c78352e58"}, + {file = "lxml-5.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2259243ee734cc736e237719037efb86603c891fd363cc7973a2d0ac8a0e3f"}, + {file = "lxml-5.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c53164f29ed3c3868787144e8ea8a399ffd7d8215f59500a20173593c19e96eb"}, + {file = "lxml-5.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:371aab9a397dcc76625ad3b02fa9b21be63406d69237b773156e7d1fc2ce0cae"}, + {file = "lxml-5.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e08784288a179b59115b5e57abf6d387528b39abb61105fe17510a199a277a40"}, + {file = "lxml-5.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c232726f7b6df5143415a06323faaa998ef8abbe1c0ed00d718755231d76f08"}, + {file = "lxml-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4366e58c0508da4dee4c7c70cee657e38553d73abdffa53abbd7d743711ee11"}, + {file = "lxml-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c84dce8fb2e900d4fb094e76fdad34a5fd06de53e41bddc1502c146eb11abd74"}, + {file = "lxml-5.2.0-cp311-cp311-win32.whl", hash = "sha256:0947d1114e337dc2aae2fa14bbc9ed5d9ca1a0acd6d2f948df9926aef65305e9"}, + {file = "lxml-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1eace37a9f4a1bef0bb5c849434933fd6213008ec583c8e31ee5b8e99c7c8500"}, + {file = "lxml-5.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f2cb157e279d28c66b1c27e0948687dc31dc47d1ab10ce0cd292a8334b7de3d5"}, + {file = "lxml-5.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:53c0e56f41ef68c1ce4e96f27ecdc2df389730391a2fd45439eb3facb02d36c8"}, + {file = "lxml-5.2.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:703d60e59ab45c17485c2c14b11880e4f7f0eab07134afa9007573fa5a779a5a"}, + {file = "lxml-5.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaf5e308a5e50bc0548c4fdca0117a31ec9596f8cfc96592db170bcecc71a957"}, + {file = "lxml-5.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af64df85fecd3cf3b2e792f0b5b4d92740905adfa8ce3b24977a55415f1a0c40"}, + {file = "lxml-5.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:df7dfbdef11702fd22c2eaf042d7098d17edbc62d73f2199386ad06cbe466f6d"}, + {file = "lxml-5.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7250030a7835bfd5ba6ca7d1ad483ec90f9cbc29978c5e75c1cc3e031d3c4160"}, + {file = "lxml-5.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:be5faa2d5c8c8294d770cfd09d119fb27b5589acc59635b0cf90f145dbe81dca"}, + {file = "lxml-5.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:347ec08250d5950f5b016caa3e2e13fb2cb9714fe6041d52e3716fb33c208663"}, + {file = "lxml-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc7b630c4fb428b8a40ddd0bfc4bc19de11bb3c9b031154f77360e48fe8b4451"}, + {file = "lxml-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ae550cbd7f229cdf2841d9b01406bcca379a5fb327b9efb53ba620a10452e835"}, + {file = "lxml-5.2.0-cp312-cp312-win32.whl", hash = "sha256:7c61ce3cdd6e6c9f4003ac118be7eb3036d0ce2afdf23929e533e54482780f74"}, + {file = "lxml-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f90c36ca95a44d2636bbf55a51ca30583b59b71b6547b88d954e029598043551"}, + {file = "lxml-5.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1cce2eaad7e38b985b0f91f18468dda0d6b91862d32bec945b0e46e2ffe7222e"}, + {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60a3983d32f722a8422c01e4dc4badc7a307ca55c59e2485d0e14244a52c482f"}, + {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60847dfbdfddf08a56c4eefe48234e8c1ab756c7eda4a2a7c1042666a5516564"}, + {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bbe335f0d1a86391671d975a1b5e9b08bb72fba6b567c43bdc2e55ca6e6c086"}, + {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_28_aarch64.whl", hash = "sha256:3ac7c8a60b8ad51fe7bca99a634dd625d66492c502fd548dc6dc769ce7d94b6a"}, + {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:73e69762cf740ac3ae81137ef9d6f15f93095f50854e233d50b29e7b8a91dbc6"}, + {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:281ee1ffeb0ab06204dfcd22a90e9003f0bb2dab04101ad983d0b1773bc10588"}, + {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ba3a86b0d5a5c93104cb899dff291e3ae13729c389725a876d00ef9696de5425"}, + {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:356f8873b1e27b81793e30144229adf70f6d3e36e5cb7b6d289da690f4398953"}, + {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2a34e74ffe92c413f197ff4967fb1611d938ee0691b762d062ef0f73814f3aa4"}, + {file = "lxml-5.2.0-cp36-cp36m-win32.whl", hash = "sha256:6f0d2b97a5a06c00c963d4542793f3e486b1ed3a957f8c19f6006ed39d104bb0"}, + {file = "lxml-5.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:35e39c6fd089ad6674eb52d93aa874d6027b3ae44d2381cca6e9e4c2e102c9c8"}, + {file = "lxml-5.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5f6e4e5a62114ae76690c4a04c5108d067442d0a41fd092e8abd25af1288c450"}, + {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93eede9bcc842f891b2267c7f0984d811940d1bc18472898a1187fe560907a99"}, + {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad364026c2cebacd7e01d1138bd53639822fefa8f7da90fc38cd0e6319a2699"}, + {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f06e4460e76468d99cc36d5b9bc6fc5f43e6662af44960e13e3f4e040aacb35"}, + {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ca3236f31d565555139d5b00b790ed2a98ac6f0c4470c4032f8b5e5a5dba3c1a"}, + {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:a9b67b850ab1d304cb706cf71814b0e0c3875287083d7ec55ee69504a9c48180"}, + {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5261c858c390ae9a19aba96796948b6a2d56649cbd572968970dc8da2b2b2a42"}, + {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e8359fb610c8c444ac473cfd82dae465f405ff807cabb98a9b9712bbd0028751"}, + {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:f9e27841cddfaebc4e3ffbe5dbdff42891051acf5befc9f5323944b2c61cef16"}, + {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:641a8da145aca67671205f3e89bfec9815138cf2fe06653c909eab42e486d373"}, + {file = "lxml-5.2.0-cp37-cp37m-win32.whl", hash = "sha256:931a3a13e0f574abce8f3152b207938a54304ccf7a6fd7dff1fdb2f6691d08af"}, + {file = "lxml-5.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:246c93e2503c710cf02c7e9869dc0258223cbefe5e8f9ecded0ac0aa07fd2bf8"}, + {file = "lxml-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:11acfcdf5a38cf89c48662123a5d02ae0a7d99142c7ee14ad90de5c96a9b6f06"}, + {file = "lxml-5.2.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:200f70b5d95fc79eb9ed7f8c4888eef4e274b9bf380b829d3d52e9ed962e9231"}, + {file = "lxml-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba4d02aed47c25be6775a40d55c5774327fdedba79871b7c2485e80e45750cb2"}, + {file = "lxml-5.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e283b24c14361fe9e04026a1d06c924450415491b83089951d469509900d9f32"}, + {file = "lxml-5.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:03e3962d6ad13a862dacd5b3a3ea60b4d092a550f36465234b8639311fd60989"}, + {file = "lxml-5.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6e45fd5213e5587a610b7e7c8c5319a77591ab21ead42df46bb342e21bc1418d"}, + {file = "lxml-5.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:27877732946843f4b6bfc56eb40d865653eef34ad2edeed16b015d5c29c248df"}, + {file = "lxml-5.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4d16b44ad0dd8c948129639e34c8d301ad87ebc852568ace6fe9a5ad9ce67ee1"}, + {file = "lxml-5.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b8f842df9ba26135c5414e93214e04fe0af259bb4f96a32f756f89467f7f3b45"}, + {file = "lxml-5.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c74e77df9e36c8c91157853e6cd400f6f9ca7a803ba89981bfe3f3fc7e5651ef"}, + {file = "lxml-5.2.0-cp38-cp38-win32.whl", hash = "sha256:1459a998c10a99711ac532abe5cc24ba354e4396dafef741c7797f8830712d56"}, + {file = "lxml-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:a00f5931b7cccea775123c3c0a2513aee58afdad8728550cc970bff32280bdd2"}, + {file = "lxml-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ddda5ba8831f258ac7e6364be03cb27aa62f50c67fd94bc1c3b6247959cc0369"}, + {file = "lxml-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56835b9e9a7767202fae06310c6b67478963e535fe185bed3bf9af5b18d2b67e"}, + {file = "lxml-5.2.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25fef8794f0dc89f01bdd02df6a7fec4bcb2fbbe661d571e898167a83480185e"}, + {file = "lxml-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d44af078485c4da9a7ec460162392d49d996caf89516fa0b75ad0838047122"}, + {file = "lxml-5.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f354d62345acdf22aa3e171bd9723790324a66fafe61bfe3873b86724cf6daaa"}, + {file = "lxml-5.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6a7e0935f05e1cf1a3aa1d49a87505773b04f128660eac2a24a5594ea6b1baa7"}, + {file = "lxml-5.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:75a4117b43694c72a0d89f6c18a28dc57407bde4650927d4ef5fd384bdf6dcc7"}, + {file = "lxml-5.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:57402d6cdd8a897ce21cf8d1ff36683583c17a16322a321184766c89a1980600"}, + {file = "lxml-5.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:56591e477bea531e5e1854f5dfb59309d5708669bc921562a35fd9ca5182bdcd"}, + {file = "lxml-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7efbce96719aa275d49ad5357886845561328bf07e1d5ab998f4e3066c5ccf15"}, + {file = "lxml-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a3c39def0965e8fb5c8d50973e0c7b4ce429a2fa730f3f9068a7f4f9ce78410b"}, + {file = "lxml-5.2.0-cp39-cp39-win32.whl", hash = "sha256:5188f22c00381cb44283ecb28c8d85c2db4a3035774dd851876c8647cb809c27"}, + {file = "lxml-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:ed1fe80e1fcdd1205a443bddb1ad3c3135bb1cd3f36cc996a1f4aed35960fbe8"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d2b339fb790fc923ae2e9345c8633e3d0064d37ea7920c027f20c8ae6f65a91f"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06036d60fccb21e22dd167f6d0e422b9cbdf3588a7e999a33799f9cbf01e41a5"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1611fb9de0a269c05575c024e6d8cdf2186e3fa52b364e3b03dcad82514d57"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:05fc3720250d221792b6e0d150afc92d20cb10c9cdaa8c8f93c2a00fbdd16015"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:11e41ffd3cd27b0ca1c76073b27bd860f96431d9b70f383990f1827ca19f2f52"}, + {file = "lxml-5.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0382e6a3eefa3f6699b14fa77c2eb32af2ada261b75120eaf4fc028a20394975"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:be5c8e776ecbcf8c1bce71a7d90e3a3680c9ceae516cac0be08b47e9fac0ca43"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da12b4efc93d53068888cb3b58e355b31839f2428b8f13654bd25d68b201c240"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f8033da364bacc74aca5e319509a20bb711c8a133680ca5f35020f9eaf025"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:50a26f68d090594477df8572babac64575cd5c07373f7a8319c527c8e56c0f99"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:57cbadf028727705086047994d2e50124650e63ce5a035b0aa79ab50f001989f"}, + {file = "lxml-5.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:8aa11638902ac23f944f16ce45c9f04c9d5d57bb2da66822abb721f4efe5fdbb"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7150e630b879390e02121e71ceb1807f682b88342e2ea2082e2c8716cf8bd93"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4add722393c99da4d51c8d9f3e1ddf435b30677f2d9ba9aeaa656f23c1b7b580"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd0f25a431cd16f70ec1c47c10b413e7ddfe1ccaaddd1a7abd181e507c012374"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:883e382695f346c2ea3ad96bdbdf4ca531788fbeedb4352be3a8fcd169fc387d"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:80cc2b55bb6e35d3cb40936b658837eb131e9f16357241cd9ba106ae1e9c5ecb"}, + {file = "lxml-5.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:59ec2948385336e9901008fdf765780fe30f03e7fdba8090aafdbe5d1b7ea0cd"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ddbea6e58cce1a640d9d65947f1e259423fc201c9cf9761782f355f53b7f3097"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52d6cdea438eb7282c41c5ac00bd6d47d14bebb6e8a8d2a1c168ed9e0cacfbab"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c556bbf88a8b667c849d326dd4dd9c6290ede5a33383ffc12b0ed17777f909d"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:947fa8bf15d1c62c6db36c6ede9389cac54f59af27010251747f05bddc227745"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e6cb8f7a332eaa2d876b649a748a445a38522e12f2168e5e838d1505a91cdbb7"}, + {file = "lxml-5.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:16e65223f34fd3d65259b174f0f75a4bb3d9893698e5e7d01e54cd8c5eb98d85"}, + {file = "lxml-5.2.0.tar.gz", hash = "sha256:21dc490cdb33047bc7f7ad76384f3366fa8f5146b86cc04c4af45de901393b90"}, ] [package.extras] cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.7)"] +source = ["Cython (>=3.0.10)"] + +[[package]] +name = "mako" +version = "1.3.2" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.2-py3-none-any.whl", hash = "sha256:32a99d70754dfce237019d17ffe4a282d2d3351b9c476e90d8a60e63f133b80c"}, + {file = "Mako-1.3.2.tar.gz", hash = "sha256:2a0c8ad7f6274271b3bb7467dd37cf9cc6dab4bc19cb69a4ef10669402de698e"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] [[package]] name = "mapbox-vector-tile" @@ -890,30 +1073,6 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] -[[package]] -name = "motor" -version = "3.3.2" -description = "Non-blocking MongoDB driver for Tornado or asyncio" -optional = false -python-versions = ">=3.7" -files = [ - {file = "motor-3.3.2-py3-none-any.whl", hash = "sha256:6fe7e6f0c4f430b9e030b9d22549b732f7c2226af3ab71ecc309e4a1b7d19953"}, - {file = "motor-3.3.2.tar.gz", hash = "sha256:d2fc38de15f1c8058f389c1a44a4d4105c0405c48c061cd492a654496f7bc26a"}, -] - -[package.dependencies] -pymongo = ">=4.5,<5" - -[package.extras] -aws = ["pymongo[aws] (>=4.5,<5)"] -encryption = ["pymongo[encryption] (>=4.5,<5)"] -gssapi = ["pymongo[gssapi] (>=4.5,<5)"] -ocsp = ["pymongo[ocsp] (>=4.5,<5)"] -snappy = ["pymongo[snappy] (>=4.5,<5)"] -srv = ["pymongo[srv] (>=4.5,<5)"] -test = ["aiohttp (<3.8.6)", "mockupdb", "motor[encryption]", "pytest (>=7)", "tornado (>=5)"] -zstd = ["pymongo[zstd] (>=4.5,<5)"] - [[package]] name = "msgspec" version = "0.18.6" @@ -1013,79 +1172,80 @@ files = [ [[package]] name = "pillow" -version = "10.2.0" +version = "10.3.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" files = [ - {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"}, - {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"}, - {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"}, - {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"}, - {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"}, - {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"}, - {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"}, - {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"}, - {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"}, - {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"}, - {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"}, - {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"}, - {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"}, - {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"}, - {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"}, - {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"}, - {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"}, - {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"}, - {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"}, - {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"}, - {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"}, - {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"}, - {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"}, - {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"}, - {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"}, - {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"}, - {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"}, - {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"}, - {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"}, - {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"}, - {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"}, - {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"}, - {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"}, - {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"}, - {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"}, - {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"}, - {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"}, - {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"}, - {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"}, - {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"}, - {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"}, - {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"}, - {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, + {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, + {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, + {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, + {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, + {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, + {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, + {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, + {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, + {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, + {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, + {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, + {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, + {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, + {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, + {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, ] [package.extras] @@ -1171,24 +1331,24 @@ files = [ [[package]] name = "pycparser" -version = "2.21" +version = "2.22" description = "C parser in Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] [[package]] name = "pydantic" -version = "2.6.3" +version = "2.6.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, - {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, ] [package.dependencies] @@ -1366,109 +1526,6 @@ examples = ["django", "numpy"] test = ["flaky", "greenlet (>=3.0.0a1)", "ipython", "pytest", "pytest-asyncio (==0.12.0)", "sphinx-autobuild (==2021.3.14)", "trio"] types = ["typing-extensions"] -[[package]] -name = "pymongo" -version = "4.6.2" -description = "Python driver for MongoDB " -optional = false -python-versions = ">=3.7" -files = [ - {file = "pymongo-4.6.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7640d176ee5b0afec76a1bda3684995cb731b2af7fcfd7c7ef8dc271c5d689af"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux1_i686.whl", hash = "sha256:4e2129ec8f72806751b621470ac5d26aaa18fae4194796621508fa0e6068278a"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:c43205e85cbcbdf03cff62ad8f50426dd9d20134a915cfb626d805bab89a1844"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:91ddf95cedca12f115fbc5f442b841e81197d85aa3cc30b82aee3635a5208af2"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:0fbdbf2fba1b4f5f1522e9f11e21c306e095b59a83340a69e908f8ed9b450070"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:097791d5a8d44e2444e0c8c4d6e14570ac11e22bcb833808885a5db081c3dc2a"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:e0b208ebec3b47ee78a5c836e2e885e8c1e10f8ffd101aaec3d63997a4bdcd04"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1849fd6f1917b4dc5dbf744b2f18e41e0538d08dd8e9ba9efa811c5149d665a3"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa0bbbfbd1f8ebbd5facaa10f9f333b20027b240af012748555148943616fdf3"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4522ad69a4ab0e1b46a8367d62ad3865b8cd54cf77518c157631dac1fdc97584"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397949a9cc85e4a1452f80b7f7f2175d557237177120954eff00bf79553e89d3"}, - {file = "pymongo-4.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d511db310f43222bc58d811037b176b4b88dc2b4617478c5ef01fea404f8601"}, - {file = "pymongo-4.6.2-cp310-cp310-win32.whl", hash = "sha256:991e406db5da4d89fb220a94d8caaf974ffe14ce6b095957bae9273c609784a0"}, - {file = "pymongo-4.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:94637941fe343000f728e28d3fe04f1f52aec6376b67b85583026ff8dab2a0e0"}, - {file = "pymongo-4.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:84593447a5c5fe7a59ba86b72c2c89d813fbac71c07757acdf162fbfd5d005b9"}, - {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aebddb2ec2128d5fc2fe3aee6319afef8697e0374f8a1fcca3449d6f625e7b4"}, - {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f706c1a644ed33eaea91df0a8fb687ce572b53eeb4ff9b89270cb0247e5d0e1"}, - {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18c422e6b08fa370ed9d8670c67e78d01f50d6517cec4522aa8627014dfa38b6"}, - {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d002ae456a15b1d790a78bb84f87af21af1cb716a63efb2c446ab6bcbbc48ca"}, - {file = "pymongo-4.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f86ba0c781b497a3c9c886765d7b6402a0e3ae079dd517365044c89cd7abb06"}, - {file = "pymongo-4.6.2-cp311-cp311-win32.whl", hash = "sha256:ac20dd0c7b42555837c86f5ea46505f35af20a08b9cf5770cd1834288d8bd1b4"}, - {file = "pymongo-4.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:e78af59fd0eb262c2a5f7c7d7e3b95e8596a75480d31087ca5f02f2d4c6acd19"}, - {file = "pymongo-4.6.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6125f73503407792c8b3f80165f8ab88a4e448d7d9234c762681a4d0b446fcb4"}, - {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba052446a14bd714ec83ca4e77d0d97904f33cd046d7bb60712a6be25eb31dbb"}, - {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b65433c90e07dc252b4a55dfd885ca0df94b1cf77c5b8709953ec1983aadc03"}, - {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2160d9c8cd20ce1f76a893f0daf7c0d38af093f36f1b5c9f3dcf3e08f7142814"}, - {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f251f287e6d42daa3654b686ce1fcb6d74bf13b3907c3ae25954978c70f2cd4"}, - {file = "pymongo-4.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d227a60b00925dd3aeae4675575af89c661a8e89a1f7d1677e57eba4a3693c"}, - {file = "pymongo-4.6.2-cp312-cp312-win32.whl", hash = "sha256:311794ef3ccae374aaef95792c36b0e5c06e8d5cf04a1bdb1b2bf14619ac881f"}, - {file = "pymongo-4.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:f673b64a0884edcc56073bda0b363428dc1bf4eb1b5e7d0b689f7ec6173edad6"}, - {file = "pymongo-4.6.2-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:fe010154dfa9e428bd2fb3e9325eff2216ab20a69ccbd6b5cac6785ca2989161"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1f5f4cd2969197e25b67e24d5b8aa2452d381861d2791d06c493eaa0b9c9fcfe"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c9519c9d341983f3a1bd19628fecb1d72a48d8666cf344549879f2e63f54463b"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c68bf4a399e37798f1b5aa4f6c02886188ef465f4ac0b305a607b7579413e366"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a509db602462eb736666989739215b4b7d8f4bb8ac31d0bffd4be9eae96c63ef"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:362a5adf6f3f938a8ff220a4c4aaa93e84ef932a409abecd837c617d17a5990f"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:ee30a9d4c27a88042d0636aca0275788af09cc237ae365cd6ebb34524bddb9cc"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:477914e13501bb1d4608339ee5bb618be056d2d0e7267727623516cfa902e652"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd343ca44982d480f1e39372c48e8e263fc6f32e9af2be456298f146a3db715"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3797e0a628534e07a36544d2bfa69e251a578c6d013e975e9e3ed2ac41f2d95"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97d81d357e1a2a248b3494d52ebc8bf15d223ee89d59ee63becc434e07438a24"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed694c0d1977cb54281cb808bc2b247c17fb64b678a6352d3b77eb678ebe1bd9"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ceaaff4b812ae368cf9774989dea81b9bbb71e5bed666feca6a9f3087c03e49"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7dd63f7c2b3727541f7f37d0fb78d9942eb12a866180fbeb898714420aad74e2"}, - {file = "pymongo-4.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e571434633f99a81e081738721bb38e697345281ed2f79c2f290f809ba3fbb2f"}, - {file = "pymongo-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:3e9f6e2f3da0a6af854a3e959a6962b5f8b43bbb8113cd0bff0421c5059b3106"}, - {file = "pymongo-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:3a5280f496297537301e78bde250c96fadf4945e7b2c397d8bb8921861dd236d"}, - {file = "pymongo-4.6.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5f6bcd2d012d82d25191a911a239fd05a8a72e8c5a7d81d056c0f3520cad14d1"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4fa30494601a6271a8b416554bd7cde7b2a848230f0ec03e3f08d84565b4bf8c"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bea62f03a50f363265a7a651b4e2a4429b4f138c1864b2d83d4bf6f9851994be"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b2d445f1cf147331947cc35ec10342f898329f29dd1947a3f8aeaf7e0e6878d1"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:5db133d6ec7a4f7fc7e2bd098e4df23d7ad949f7be47b27b515c9fb9301c61e4"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:9eec7140cf7513aa770ea51505d312000c7416626a828de24318fdcc9ac3214c"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:5379ca6fd325387a34cda440aec2bd031b5ef0b0aa2e23b4981945cff1dab84c"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:579508536113dbd4c56e4738955a18847e8a6c41bf3c0b4ab18b51d81a6b7be8"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3bae553ca39ed52db099d76acd5e8566096064dc7614c34c9359bb239ec4081"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0257e0eebb50f242ca28a92ef195889a6ad03dcdde5bf1c7ab9f38b7e810801"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbafe3a1df21eeadb003c38fc02c1abf567648b6477ec50c4a3c042dca205371"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaecfafb407feb6f562c7f2f5b91f22bfacba6dd739116b1912788cff7124c4a"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e942945e9112075a84d2e2d6e0d0c98833cdcdfe48eb8952b917f996025c7ffa"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f7b98f8d2cf3eeebde738d080ae9b4276d7250912d9751046a9ac1efc9b1ce2"}, - {file = "pymongo-4.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8110b78fc4b37dced85081d56795ecbee6a7937966e918e05e33a3900e8ea07d"}, - {file = "pymongo-4.6.2-cp38-cp38-win32.whl", hash = "sha256:df813f0c2c02281720ccce225edf39dc37855bf72cdfde6f789a1d1cf32ffb4b"}, - {file = "pymongo-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:64ec3e2dcab9af61bdbfcb1dd863c70d1b0c220b8e8ac11df8b57f80ee0402b3"}, - {file = "pymongo-4.6.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bff601fbfcecd2166d9a2b70777c2985cb9689e2befb3278d91f7f93a0456cae"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f1febca6f79e91feafc572906871805bd9c271b6a2d98a8bb5499b6ace0befed"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d788cb5cc947d78934be26eef1623c78cec3729dc93a30c23f049b361aa6d835"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5c2f258489de12a65b81e1b803a531ee8cf633fa416ae84de65cd5f82d2ceb37"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:fb24abcd50501b25d33a074c1790a1389b6460d2509e4b240d03fd2e5c79f463"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:4d982c6db1da7cf3018183891883660ad085de97f21490d314385373f775915b"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:b2dd8c874927a27995f64a3b44c890e8a944c98dec1ba79eab50e07f1e3f801b"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:4993593de44c741d1e9f230f221fe623179f500765f9855936e4ff6f33571bad"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:658f6c028edaeb02761ebcaca8d44d519c22594b2a51dcbc9bd2432aa93319e3"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68109c13176749fbbbbbdb94dd4a58dcc604db6ea43ee300b2602154aebdd55f"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707d28a822b918acf941cff590affaddb42a5d640614d71367c8956623a80cbc"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f251db26c239aec2a4d57fbe869e0a27b7f6b5384ec6bf54aeb4a6a5e7408234"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57c05f2e310701fc17ae358caafd99b1830014e316f0242d13ab6c01db0ab1c2"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b575fbe6396bbf21e4d0e5fd2e3cdb656dc90c930b6c5532192e9a89814f72d"}, - {file = "pymongo-4.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ca5877754f3fa6e4fe5aacf5c404575f04c2d9efc8d22ed39576ed9098d555c8"}, - {file = "pymongo-4.6.2-cp39-cp39-win32.whl", hash = "sha256:8caa73fb19070008e851a589b744aaa38edd1366e2487284c61158c77fdf72af"}, - {file = "pymongo-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:3e03c732cb64b96849310e1d8688fb70d75e2571385485bf2f1e7ad1d309fa53"}, - {file = "pymongo-4.6.2.tar.gz", hash = "sha256:ab7d01ac832a1663dad592ccbd92bb0f0775bc8f98a1923c5e1a7d7fead495af"}, -] - -[package.dependencies] -dnspython = ">=1.16.0,<3.0.0" - -[package.extras] -aws = ["pymongo-auth-aws (<2.0.0)"] -encryption = ["certifi", "pymongo[aws]", "pymongocrypt (>=1.6.0,<2.0.0)"] -gssapi = ["pykerberos", "winkerberos (>=0.5.0)"] -ocsp = ["certifi", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] -snappy = ["python-snappy"] -test = ["pytest (>=7)"] -zstd = ["zstandard"] - [[package]] name = "pyproj" version = "3.6.1" @@ -1717,20 +1774,20 @@ test = ["asv", "gmpy2", "hypothesis", "mpmath", "pooch", "pytest", "pytest-cov", [[package]] name = "sentry-sdk" -version = "1.41.0" +version = "1.44.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.41.0.tar.gz", hash = "sha256:4f2d6c43c07925d8cd10dfbd0970ea7cb784f70e79523cca9dbcd72df38e5a46"}, - {file = "sentry_sdk-1.41.0-py2.py3-none-any.whl", hash = "sha256:be4f8f4b29a80b6a3b71f0f31487beb9e296391da20af8504498a328befed53f"}, + {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, + {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, ] [package.dependencies] certifi = "*" fastapi = {version = ">=0.79.0", optional = true, markers = "extra == \"fastapi\""} httpx = {version = ">=0.16.0", optional = true, markers = "extra == \"httpx\""} -pymongo = {version = ">=3.1", optional = true, markers = "extra == \"pymongo\""} +sqlalchemy = {version = ">=1.2", optional = true, markers = "extra == \"sqlalchemy\""} urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} [package.extras] @@ -1740,6 +1797,7 @@ asyncpg = ["asyncpg (>=0.23)"] beam = ["apache-beam (>=2.12)"] bottle = ["bottle (>=0.12.13)"] celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] chalice = ["chalice (>=1.16.0)"] clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] django = ["django (>=1.8)"] @@ -1750,6 +1808,7 @@ grpcio = ["grpcio (>=1.21.1)"] httpx = ["httpx (>=0.16.0)"] huey = ["huey (>=2)"] loguru = ["loguru (>=0.5)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"] opentelemetry-experimental = ["opentelemetry-distro (>=0.40b0,<1.0)", "opentelemetry-instrumentation-aiohttp-client (>=0.40b0,<1.0)", "opentelemetry-instrumentation-django (>=0.40b0,<1.0)", "opentelemetry-instrumentation-fastapi (>=0.40b0,<1.0)", "opentelemetry-instrumentation-flask (>=0.40b0,<1.0)", "opentelemetry-instrumentation-requests (>=0.40b0,<1.0)", "opentelemetry-instrumentation-sqlite3 (>=0.40b0,<1.0)", "opentelemetry-instrumentation-urllib (>=0.40b0,<1.0)"] pure-eval = ["asttokens", "executing", "pure-eval"] @@ -1763,6 +1822,22 @@ starlette = ["starlette (>=0.19.1)"] starlite = ["starlite (>=1.48)"] tornado = ["tornado (>=5)"] +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "shapely" version = "2.0.3" @@ -1853,15 +1928,103 @@ files = [ {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.29" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-win32.whl", hash = "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f"}, + {file = "SQLAlchemy-2.0.29-cp310-cp310-win_amd64.whl", hash = "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-win32.whl", hash = "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520"}, + {file = "SQLAlchemy-2.0.29-cp311-cp311-win_amd64.whl", hash = "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-win32.whl", hash = "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41"}, + {file = "SQLAlchemy-2.0.29-cp312-cp312-win_amd64.whl", hash = "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-win32.whl", hash = "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd"}, + {file = "SQLAlchemy-2.0.29-cp37-cp37m-win_amd64.whl", hash = "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-win32.whl", hash = "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b"}, + {file = "SQLAlchemy-2.0.29-cp38-cp38-win_amd64.whl", hash = "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-win32.whl", hash = "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec"}, + {file = "SQLAlchemy-2.0.29-cp39-cp39-win_amd64.whl", hash = "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c"}, + {file = "SQLAlchemy-2.0.29-py3-none-any.whl", hash = "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305"}, + {file = "SQLAlchemy-2.0.29.tar.gz", hash = "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0"}, +] + +[package.dependencies] +asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql-asyncpg\""} +greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"postgresql-asyncpg\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + [[package]] name = "starlette" -version = "0.36.3" +version = "0.37.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, - {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, ] [package.dependencies] @@ -1870,15 +2033,32 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "supervisor" +version = "4.2.5" +description = "A system for controlling process state under UNIX" +optional = false +python-versions = "*" +files = [ + {file = "supervisor-4.2.5-py2.py3-none-any.whl", hash = "sha256:2ecaede32fc25af814696374b79e42644ecaba5c09494c51016ffda9602d0f08"}, + {file = "supervisor-4.2.5.tar.gz", hash = "sha256:34761bae1a23c58192281a5115fb07fbf22c9b0133c08166beffc70fed3ebc12"}, +] + +[package.dependencies] +setuptools = "*" + +[package.extras] +testing = ["pytest", "pytest-cov"] + [[package]] name = "threadpoolctl" -version = "3.3.0" +version = "3.4.0" description = "threadpoolctl" optional = false python-versions = ">=3.8" files = [ - {file = "threadpoolctl-3.3.0-py3-none-any.whl", hash = "sha256:6155be1f4a39f31a18ea70f94a77e0ccd57dced08122ea61109e7da89883781e"}, - {file = "threadpoolctl-3.3.0.tar.gz", hash = "sha256:5dac632b4fa2d43f42130267929af3ba01399ef4bd1882918e92dbc30365d30c"}, + {file = "threadpoolctl-3.4.0-py3-none-any.whl", hash = "sha256:8f4c689a65b23e5ed825c8436a92b818aac005e0f3715f6a1664d7c7ee29d262"}, + {file = "threadpoolctl-3.4.0.tar.gz", hash = "sha256:f11b491a03661d6dd7ef692dd422ab34185d982466c49c8f98c8f716b5c93196"}, ] [[package]] @@ -1894,63 +2074,47 @@ files = [ [[package]] name = "tzfpy" -version = "0.15.3" +version = "0.15.4" description = "Probably the fastest Python package to convert longitude/latitude to timezone name" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tzfpy-0.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47984db8e0aabfec7daeac92fe506fd8a6916cffb0f256df1ad02079f9c73e66"}, - {file = "tzfpy-0.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:530f0d11279613edd1ce81d36770ec9523f7536624a82a57ccff2a6a1b5c3c1f"}, - {file = "tzfpy-0.15.3-cp310-cp310-manylinux_2_24_armv7l.whl", hash = "sha256:f930330b1637206157e04f126b7fdd4da7b2d31908f94b13ceeae103f75ce398"}, - {file = "tzfpy-0.15.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4e5fd1304bebec3d98946415d2535095bfdf19c7b4e80e9e8aecea88a79ed28a"}, - {file = "tzfpy-0.15.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c1efe8a8979d81a765734b1bf23dbb1eda0555cbabc31353da00f1c2a0a4e15"}, - {file = "tzfpy-0.15.3-cp310-none-win_amd64.whl", hash = "sha256:ffe161a1a4dde284c43e8ff3498fa95a44c221906f22529948a78170c4912d75"}, - {file = "tzfpy-0.15.3-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0db63c1f901b822ae9c41d532dc0cc87107fd2c742f9856569200a726f918bfd"}, - {file = "tzfpy-0.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18652ab4f05431f6ab831848997fc61a82bf006f20772358bbf5988d1ca8e63"}, - {file = "tzfpy-0.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbe3e4a449520f2964c69fe3e546431fdbaa9963be236007828c8eae3867e55b"}, - {file = "tzfpy-0.15.3-cp311-cp311-manylinux_2_24_armv7l.whl", hash = "sha256:7d5da6f2c84f8928a1576a0286bc5f65801332fc604afdf18297f90fd3c73e36"}, - {file = "tzfpy-0.15.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bf06c86d9127c04b30785215db8fae5d193d03c248ac10539639aa2e7d634141"}, - {file = "tzfpy-0.15.3-cp311-none-win_amd64.whl", hash = "sha256:145270585589e734d8c179361f448a30fe7c83f307829298ccb4380d825541fa"}, - {file = "tzfpy-0.15.3-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:397a6f962ebb554ebf6c5ac340f4ad3c57cd05be03bda7e9302a3fd1e7b47251"}, - {file = "tzfpy-0.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00543c911018788680e4c4170d032f6b19da292092186e303672912e78bd78ad"}, - {file = "tzfpy-0.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:888e7cdc1d184cbbeeadcacc1b4e607c851696ee1ece04b2b5088e85b28b2661"}, - {file = "tzfpy-0.15.3-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:123d4cb25568c2b86fe2bf0cfaa3000a1a9a78f9ca2773bc20dc013b773d25b1"}, - {file = "tzfpy-0.15.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:404ddba93938c98428da6796621e847abcbc63772b2ecb08cc6001a815a9aba5"}, - {file = "tzfpy-0.15.3-cp312-none-win_amd64.whl", hash = "sha256:20177c809690270524e206b5d02a935a856a28ade7d8eda090657233d023241f"}, - {file = "tzfpy-0.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8465ef1c1f0326e5b364ba39bdda62ab8fe9102e87d41920f48a038980980dcd"}, - {file = "tzfpy-0.15.3-cp313-cp313-manylinux_2_24_armv7l.whl", hash = "sha256:f9ba0244172cefca286422af30c46994cb8dcf30861391eaf22904f6b7fa797c"}, - {file = "tzfpy-0.15.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:503e8f5a538a43c07700973dbd4e4825b8222679aa14d3ca7f8974526ed777c8"}, - {file = "tzfpy-0.15.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c9282fb9b0a203d62b15172a3a5e3dca79b7e5d20fe7c88caf72ca10e97631"}, - {file = "tzfpy-0.15.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e097b5a934d04215b8f6754ef3073e2390b57c4320103c78afbae78e9b5addf8"}, - {file = "tzfpy-0.15.3-cp37-cp37m-manylinux_2_24_armv7l.whl", hash = "sha256:8bd57b82153a6e4efbfdb9bac81cc2cd4492b59779bdd119b72e2696f0c496bd"}, - {file = "tzfpy-0.15.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:34551ca5df5fbb4d8350078a7cd6a8bd264b19e3c996e9531e764cc829f8f288"}, - {file = "tzfpy-0.15.3-cp37-none-win_amd64.whl", hash = "sha256:209dfd0b82acabc4e977f0869177d022dcba65616d02113bd26070f8882c98d1"}, - {file = "tzfpy-0.15.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9c2e8a370e12308464193e2ad87c6a158f869635c64ff0baffbc1463828fac"}, - {file = "tzfpy-0.15.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6929ed91a15ad4dbe3594d3a2adbed2f129702d2c624deac510a88d5d5913a8"}, - {file = "tzfpy-0.15.3-cp38-cp38-manylinux_2_24_armv7l.whl", hash = "sha256:948084425396422d900906222836dd99d499b797eb4ad092804c0383df5700f0"}, - {file = "tzfpy-0.15.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5e7610ccd669623fd28564b05ae4213c29e07fd1f4e220c549dc3ba8774679d9"}, - {file = "tzfpy-0.15.3-cp38-none-win_amd64.whl", hash = "sha256:df2e2dc4ed1909bb4cdaf3883951ffa1db48fa86d551ebc12b7e16065d9d8195"}, - {file = "tzfpy-0.15.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6a52e3d9c7e662a18e87dd2aa95dafc2e41ac2a93776c4a13df99d4f697f0bf"}, - {file = "tzfpy-0.15.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c584f8d816cf1722c8dd780f76e1a8e24cd60ba877ca371a03f418f82b635b3b"}, - {file = "tzfpy-0.15.3-cp39-cp39-manylinux_2_24_armv7l.whl", hash = "sha256:1a35da73bbb303e2596ae5e2dde6f5e1ca8a334eff5a175c6ee72dae29828009"}, - {file = "tzfpy-0.15.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ece8f1828cfc4f9f580154bb87a8777f03902b9cfadf2b4fbc964efce62d13d5"}, - {file = "tzfpy-0.15.3-cp39-none-win_amd64.whl", hash = "sha256:3ec90ffcee4a5246617d1e07d1a5c9dec53357dcfbe910c31c8fddc143d01895"}, - {file = "tzfpy-0.15.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d46f9fa330d78d0bf91524ef22adf1b1fbb9c9d48db20e51e7d58912e1a0307"}, - {file = "tzfpy-0.15.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7ae2f7a2cb7cb27d11f491e89f6b9a7b26ee631220a65831cf0802394935e6d"}, - {file = "tzfpy-0.15.3-pp310-pypy310_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:c79c1c105ebddc4446eb18698d7427b5b46b4bc5b312affb9c785f9398523bca"}, - {file = "tzfpy-0.15.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e39dd484406b9560637c7a3fd537ca80ddd6017e7bd576568674fc577c0583fd"}, - {file = "tzfpy-0.15.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a360e2ee1e5866ee684f31512d7f4e202d68fb67d1ea50480649c645a5f46ab3"}, - {file = "tzfpy-0.15.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:700503a390ea1deadfa6dcab445dbdbabb021e94f291091611bb02ca36522677"}, - {file = "tzfpy-0.15.3-pp37-pypy37_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:09b3de6648f899a1b39a4ae1df55195714550c08d949f4eb708ab59f39a00325"}, - {file = "tzfpy-0.15.3-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ceeb9384bfbf744b31a400c51d237d08c1ffc612be30326a92fb648ebe4268ac"}, - {file = "tzfpy-0.15.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d8ece4474fdff3e643ed4500c09fafdb6685b96245dd0800fd20a074526c42"}, - {file = "tzfpy-0.15.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915e5d7911d08009f147a82580eaff1660a9e4493767f036459d66c4eb6f59b"}, - {file = "tzfpy-0.15.3-pp38-pypy38_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:8ac3fdf808c36b5a26935d4609025f9c6ee46554447efa6f4085d96457134081"}, - {file = "tzfpy-0.15.3-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:8e0c6abc9e7553285daff41eecc89eff36bb0d423564b65ea2207cf7255f7310"}, - {file = "tzfpy-0.15.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7e07fc29cf2772a9529d8e0fc751ce8510a777f9be296e7eb120319d3bd9347"}, - {file = "tzfpy-0.15.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd62c862607e80c7e1d8d8026894888e86b986e3d5bed1d038e83cc7d47e8aad"}, - {file = "tzfpy-0.15.3-pp39-pypy39_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:e37172732501e8d278c9906e679dfef7809708a672710a4f9c71de38c108fa35"}, - {file = "tzfpy-0.15.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3300630ebc378606ff3e0e3fa627a390a87a13898245f14c2faafdfbf3974b57"}, + {file = "tzfpy-0.15.4-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:699fdd7c136ad5f4395e262d643cedba55f15ba415870284daccd06d0b43afc7"}, + {file = "tzfpy-0.15.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85addde75ef67e7360747943c789ee5b40171372c654014195c493d70eb275d6"}, + {file = "tzfpy-0.15.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8e0b2096ee075674b6f3938339c1f224db99e85526446e61298f0578e18e41"}, + {file = "tzfpy-0.15.4-cp310-cp310-manylinux_2_24_armv7l.whl", hash = "sha256:c4915a8802c1a1eeb48e98b4f01c57afb0430cb2049171a1ba87ab24885d910d"}, + {file = "tzfpy-0.15.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c2d4af2a0c9a3b7fbcfa87c2f71a6ea9519dbc10806a813af9dca6dd2d919961"}, + {file = "tzfpy-0.15.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1254d3350e22f53e626faa5c8bf8b0b494c1038d7e16122840c6bf73dca9dd4"}, + {file = "tzfpy-0.15.4-cp310-none-win_amd64.whl", hash = "sha256:04286bd080210d5a8700344387cc4596f522a1957befaf657aee6063f2b832ad"}, + {file = "tzfpy-0.15.4-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cdb820af771fdf593d7dc761f47d4276a02a175ac2d01a9946c91056f8fe6a38"}, + {file = "tzfpy-0.15.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f477c1b50a8a890d4ce86cc55dcef3c5805bb83966690ff507c9e8f15806f8de"}, + {file = "tzfpy-0.15.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35de2447b36545ad78e5b7093120fca27690486dbf6a589ad292db9b419f951"}, + {file = "tzfpy-0.15.4-cp311-cp311-manylinux_2_24_armv7l.whl", hash = "sha256:87e2c85c55a310449f7bf870b7c214f830b7550f814b2301207f58403a6862c3"}, + {file = "tzfpy-0.15.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f2f135b20f5e7e214c247df20a0c1c1dbf62155c70d0bfc1ebbb788a954ab8e3"}, + {file = "tzfpy-0.15.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2712be3893868e47f11b20aa0b05cb193a8523c93aac182816b7a48c394179ea"}, + {file = "tzfpy-0.15.4-cp311-none-win_amd64.whl", hash = "sha256:253b10fd77630e111de4efae4d9f83b2bf84d754083101e4aa38f63cdd902530"}, + {file = "tzfpy-0.15.4-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:01cb5928806ca0fe0da5d345c91d2a607ae8b1b91e41135c903c678f45820ab0"}, + {file = "tzfpy-0.15.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c3106dc01bb73837f0928d040a1458584cab4543e7bf8dce7f0d12a4bbae9d5"}, + {file = "tzfpy-0.15.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a55dbaee78299e569ac3b524afe9f82576096c90cb11315b27c14b410bd943"}, + {file = "tzfpy-0.15.4-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:22b5a0845a4bebf2a257cfb7648ab7e32d5bc7e3255e459e0aa55ca78a3a63d4"}, + {file = "tzfpy-0.15.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8e7000cf710bb7193f3f2ce57d2a9bc0623ef5799551f2541244ac8c9ac6a9ea"}, + {file = "tzfpy-0.15.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:eca50f905b622f557027a3c2b67cd5b1d7cb70a45f5ea36ebf26bbba2b2017f6"}, + {file = "tzfpy-0.15.4-cp312-none-win_amd64.whl", hash = "sha256:3d5e122705f7924dec6e019d21c2646a6cfb12dba5544ccb3ef6cbfe58bb7e94"}, + {file = "tzfpy-0.15.4-cp38-cp38-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:160bb4fc29f36e0eed534ed56b9ad3eba8ff58b50416b4d3a654cd59931216e1"}, + {file = "tzfpy-0.15.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f5cfaa27209256d1fa16213536bdc57899ea17d4d5bc3da5c52ea5601078f4a"}, + {file = "tzfpy-0.15.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cb17837ab15d06d76252bfdc82682bf16acd22f87f4ff16f3a7142b932d7c26"}, + {file = "tzfpy-0.15.4-cp38-cp38-manylinux_2_24_armv7l.whl", hash = "sha256:71ff302bc823ef9a57c23826eeaba3e5d5c5f3814401096c2c784daffdf805ba"}, + {file = "tzfpy-0.15.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:89059e1506fdd462ba4703c0dfe3b5e43cf2352956de7668ee1febb74517c5b9"}, + {file = "tzfpy-0.15.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ee901f1ff40dd3e7df38f5e96192e70c489a6c845b379942718a80c30c4c85d9"}, + {file = "tzfpy-0.15.4-cp38-none-win_amd64.whl", hash = "sha256:950d47b5c83e1d4718233c28b2ef0960b8f23c4a33efc94eadedf10aaf5077fc"}, + {file = "tzfpy-0.15.4-cp39-cp39-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4ca74d183eb317de6a9c322b6c4a03b4bc73311ccf22b78a7d46bde739f2a127"}, + {file = "tzfpy-0.15.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80cfe48ff795ddcb64e3e07b9ab7007260a3efe0cfd89b4446be6da5ab721b21"}, + {file = "tzfpy-0.15.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b5e19e41b0463d4906763ab4645085986ef94fabb42cc3fa66c3d39491c76b"}, + {file = "tzfpy-0.15.4-cp39-cp39-manylinux_2_24_armv7l.whl", hash = "sha256:47870c5a4a49b6482a0d12596782a8afac4024fe1b9932b074200ecd533376ab"}, + {file = "tzfpy-0.15.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:70267ce01fc503aa804bb8e07c3b4041cdc5c86a4091fe77c930cd82cd5407f5"}, + {file = "tzfpy-0.15.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c95f4a42f5419c73a9ca6a5376193f1625c02fa7d3a4fc57c7f4dbc08669393d"}, + {file = "tzfpy-0.15.4-cp39-none-win_amd64.whl", hash = "sha256:dc67d5a1910cf1318d57bd4dddd8c7ce71349862128e73e71e7aa21b17b03b0f"}, + {file = "tzfpy-0.15.4.tar.gz", hash = "sha256:e64123e1886ec24f85338a97eee7f29d335608475ccc05d576b0c11de1b4f382"}, ] [package.extras] @@ -1975,13 +2139,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.27.1" +version = "0.29.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, - {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, + {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"}, + {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"}, ] [package.dependencies] @@ -2277,7 +2441,7 @@ files = [ ] [package.dependencies] -cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\""} +cffi = {version = ">=1.11", optional = true, markers = "platform_python_implementation == \"PyPy\" or extra == \"cffi\""} [package.extras] cffi = ["cffi (>=1.11)"] @@ -2285,4 +2449,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "c003123ca03001ce9974c8094b8651c9c383391da089223561b8baa376f4d06a" +content-hash = "ba9c79c168e02744c13818b319b178657930561ce06cc415230033799173eb84" diff --git a/pyproject.toml b/pyproject.toml index 28734c8..6f29d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,9 @@ readme = "README.md" version = "0.0.0" [tool.poetry.dependencies] +alembic = "^1.13.1" anyio = "^4.2.0" -asyncache = "^0.3.1" +asyncache = "<1" authlib = "^1.3.0" beautifulsoup4 = {extras = ["charset-normalizer", "lxml"], version = "^4.12.3"} cachetools = "^5.3.2" @@ -16,23 +17,24 @@ feedgen = "^1.0.0" httpx = {extras = ["brotli", "http2"], version = "<1"} jinja2 = "^3.1.2" mapbox-vector-tile = "^2.0.1" -motor = "^3.3.2" msgspec = "<1" numpy = "^1.26.3" pillow = "^10.2.0" pyinstrument = "^4.6.1" pyproj = "^3.6.1" python = "^3.12" -python-magic = "^0.4.27" +python-magic = "<1" python-multipart = "<1" pytz = "*" scikit-learn = "^1.3.2" -sentry-sdk = {extras = ["fastapi", "httpx", "pymongo"], version = "^1.40.4"} +sentry-sdk = {extras = ["fastapi", "httpx", "sqlalchemy"], version = "^1.44.0"} shapely = "^2.0.2" +sqlalchemy = {extras = ["postgresql-asyncpg"], version = "^2.0.29"} +supervisor = "^4.2.5" tzfpy = "*" uvicorn = {extras = ["standard"], version = "<1"} xmltodict = "<1" -zstandard = "<1" +zstandard = {extras = ["cffi"], version = "<1"} [build-system] build-backend = "poetry.core.masonry.api" @@ -71,8 +73,7 @@ target-version = "py312" [tool.ruff.lint] ignore = [ "ARG001", # unused argument - "S101", # assert statements - "S108", # hardcoded temp file + "ARG002", # unused positional argument ] select = [ # see https://docs.astral.sh/ruff/rules/ for rules documentation diff --git a/scripts/mongo-init-replica.sh b/scripts/mongo-init-replica.sh deleted file mode 100644 index 14fafea..0000000 --- a/scripts/mongo-init-replica.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -set -e - -echo "Initializing MongoDB replica set..." -sleep 1 - -# wait for MongoDB to start -until mongosh --host db --eval 'db.runCommand({ ping: 1 })' -do - echo "$(date) - Waiting for MongoDB to start" - sleep 1 -done - -mongosh --host db --eval "rs.initiate({_id: 'rs0', members:[{_id: 0, host:'$1'}]})" - -echo "Replica set initiated" diff --git a/services/aed_service.py b/services/aed_service.py new file mode 100644 index 0000000..1f26085 --- /dev/null +++ b/services/aed_service.py @@ -0,0 +1,309 @@ +import logging +from collections.abc import Iterable, Sequence +from itertools import batched +from operator import attrgetter, itemgetter +from time import time +from typing import NoReturn + +import anyio +import numpy as np +from asyncache import cached +from cachetools import TTLCache +from sentry_sdk import start_span, start_transaction, trace +from shapely import Point, get_coordinates, points +from shapely.geometry.base import BaseGeometry +from sklearn.cluster import Birch +from sqlalchemy import delete, func, select, text, update +from sqlalchemy.dialects.postgresql import array_agg, insert + +from config import AED_REBUILD_THRESHOLD, AED_UPDATE_DELAY +from db import db_read, db_write +from models.aed_group import AEDGroup +from models.bbox import BBox +from models.db.aed import AED +from models.db.country import Country +from overpass import query_overpass +from planet_diffs import get_planet_diffs +from services.state_service import StateService +from utils import retry_exponential + +OVERPASS_QUERY = 'node[emergency=defibrillator];out meta qt;' + + +class AEDService: + @staticmethod + async def update_db_task(*, task_status=anyio.TASK_STATUS_IGNORED) -> NoReturn: + if (await _should_update_db())[1] > 0: + task_status.started() + started = True + else: + started = False + + while True: + with start_transaction(op='db.update', name=AEDService.update_db_task.__qualname__): + await _update_db() + if not started: + task_status.started() + started = True + await anyio.sleep(AED_UPDATE_DELAY.total_seconds()) + + @classmethod + @trace + async def update_country_codes(cls) -> None: + await _assign_country_codes(await cls.get_all()) + + @staticmethod + @cached(TTLCache(maxsize=1024, ttl=3600)) + @trace + async def count_by_country_code(country_code: str) -> int: + async with db_read() as session: + stmt = select(func.count()).select_from(select(text('1')).where(AED.country_codes.any(country_code))) + return await session.scalar(stmt) + + @staticmethod + @trace + async def get_by_id(id: int) -> AED | None: + async with db_read() as session: + return await session.get(AED, id) + + @staticmethod + @trace + async def get_all() -> Sequence[AED]: + async with db_read() as session: + stmt = select(AED) + return (await session.scalars(stmt)).all() + + @classmethod + @trace + async def get_by_country_code(cls, country_code: str) -> Sequence[AED]: + async with db_read() as session: + stmt = select(AED).where(AED.country_codes.any(country_code)) + return (await session.scalars(stmt)).all() + + @classmethod + @trace + async def get_intersecting( + cls, bbox_or_geom: BBox | BaseGeometry, group_eps: float | None + ) -> Sequence[AED | AEDGroup]: + geometry = bbox_or_geom.to_polygon() if isinstance(bbox_or_geom, BBox) else bbox_or_geom + geometry_wkt = 'SRID=4326;' + geometry.wkt + + async with db_read() as session: + stmt = select(AED).where(func.ST_Intersects(AED.position, func.ST_GeomFromEWKT(geometry_wkt))) + aeds = (await session.scalars(stmt)).all() + + if len(aeds) <= 1 or group_eps is None: + return aeds + + positions = tuple(get_coordinates(aed.position)[0] for aed in aeds) + + # deterministic sampling + max_fit_samples = 7000 + if len(positions) > max_fit_samples: + indices = np.linspace(0, len(positions), max_fit_samples, endpoint=False, dtype=int) + fit_positions = np.asarray(positions)[indices] + else: + fit_positions = positions + + with start_span(description=f'Fitting model with {len(fit_positions)} samples'): + model = Birch(threshold=group_eps, n_clusters=None, compute_labels=False, copy=False) + model.fit(fit_positions) + center_points = points(model.subcluster_centers_) + + with start_span(description=f'Processing {len(aeds)} samples'): + cluster_groups: tuple[list[AED]] = tuple([] for _ in range(len(center_points))) + result: list[AED | AEDGroup] = [] + + with start_span(description='Clustering'): + clusters = model.predict(positions) + + cluster: int + for aed, cluster in zip(aeds, clusters, strict=True): + cluster_groups[cluster].append(aed) + + for group, center_point in zip(cluster_groups, center_points, strict=True): + if len(group) == 0: + continue + if len(group) == 1: + result.append(group[0]) + continue + + result.append( + AEDGroup( + position=center_point, + count=len(group), + access=AEDGroup.decide_access(aed.access for aed in group), + ) + ) + + return result + + +@trace +async def _assign_country_codes(aeds: Sequence[AED]) -> None: + aed_ids: tuple[int, ...] = tuple(map(attrgetter('id'), aeds)) + + async with db_write() as session: + for batch in batched(aed_ids, 10_000): + stmt = ( + update(AED) + .where(AED.id.in_(batch)) + .values( + { + AED.country_codes: select(array_agg(Country.code)) + .where(func.ST_Intersects(Country.geometry, AED.position)) + .scalar_subquery() + } + ) + ) + await session.execute(stmt) + + +@trace +async def _should_update_db() -> tuple[bool, float]: + doc = await StateService.get('aed') + if doc is None or doc.get('version', 1) < 3: + return True, 0 + + update_timestamp: float = doc['update_timestamp'] + update_age = time() - update_timestamp + if update_age > AED_UPDATE_DELAY.total_seconds(): + return True, update_timestamp + + return False, update_timestamp + + +@retry_exponential(None, start=4) +@trace +async def _update_db() -> None: + update_required, update_timestamp = await _should_update_db() + if not update_required: + return + + update_age = time() - update_timestamp + + if update_age > AED_REBUILD_THRESHOLD.total_seconds(): + await _update_db_snapshot() + else: + await _update_db_diffs(update_timestamp) + + +@trace +async def _update_db_snapshot() -> None: + logging.info('Updating aed database (overpass)...') + elements, data_timestamp = await query_overpass(OVERPASS_QUERY, timeout=3600, must_return=True) + aeds = tuple(_process_overpass_node(e) for e in elements) + + async with db_write() as session: + await session.execute(text(f'TRUNCATE "{AED.__tablename__}" CASCADE')) + session.add_all(aeds) + + await StateService.set('aed', {'update_timestamp': data_timestamp, 'version': 3}) + + if aeds: + logging.info('Updating country codes') + await _assign_country_codes(aeds) + + logging.info('AED update finished (=%d)', len(aeds)) + + +@trace +async def _update_db_diffs(last_update: float) -> None: + logging.info('Updating aed database (diff)...') + actions, data_timestamp = await get_planet_diffs(last_update) + + if data_timestamp <= last_update: + logging.info('Nothing to update') + return + + aeds: list[AED] = [] + remove_ids: set[int] = set() + + for action in actions: + for result in _process_action(action): + if isinstance(result, AED): + aeds.append(result) + else: + remove_ids.add(result) + + async with db_write() as session: + if aeds: + stmt = insert(AED).values( + [ + { + 'id': aed.id, + 'version': aed.version, + 'tags': aed.tags, + 'position': aed.position, + 'country_codes': None, + } + for aed in aeds + ] + ) + stmt = stmt.on_conflict_do_update( + index_elements=(AED.id,), + set_={ + 'version': stmt.excluded.version, + 'tags': stmt.excluded.tags, + 'position': stmt.excluded.position, + 'country_codes': None, + }, + ) + await session.execute(stmt) + + for batch in batched(remove_ids, 10_000): + stmt = delete(AED).where(AED.id.in_(batch)) + await session.execute(stmt) + + await StateService.set('aed', {'update_timestamp': data_timestamp, 'version': 3}) + + if aeds: + logging.info('Updating country codes') + await _assign_country_codes(aeds) + + logging.info('AED update finished (+%d, -%d)', len(aeds), len(remove_ids)) + + +def _process_action(action: dict) -> Iterable[AED | int]: + if action['@type'] in ('create', 'modify'): + return (_process_action_create_or_modify(node) for node in action['node']) + elif action['@type'] == 'delete': + return map(itemgetter('@id'), action['node']) + else: + raise NotImplementedError(f'Unknown action type: {action["@type"]}') + + +def _process_action_create_or_modify(node: dict) -> AED | int: + tags = _parse_xml_tags(node) + if _is_defibrillator(tags): + return AED( + id=node['@id'], + version=node['@version'], + tags=tags, + position=Point(node['@lon'], node['@lat']), + country_codes=None, + ) + else: + return node['@id'] + + +def _parse_xml_tags(data: dict) -> dict[str, str]: + tags = data.get('tag', []) + return {tag['@k']: tag['@v'] for tag in tags} + + +def _process_overpass_node(node: dict) -> AED: + tags = node.get('tags', {}) + if not _is_defibrillator(tags): + raise AssertionError('Unexpected non-defibrillator node') + return AED( + id=node['id'], + version=node['version'], + tags=tags, + position=Point(node['lon'], node['lat']), + country_codes=None, + ) + + +def _is_defibrillator(tags: dict[str, str]) -> bool: + return tags.get('emergency') == 'defibrillator' diff --git a/services/country_service.py b/services/country_service.py new file mode 100644 index 0000000..30e7fa3 --- /dev/null +++ b/services/country_service.py @@ -0,0 +1,127 @@ +import logging +from collections.abc import Sequence +from time import time +from typing import NoReturn + +import anyio +from sentry_sdk import start_transaction, trace +from shapely.geometry import Point +from sqlalchemy import func, select, text + +from config import COUNTRY_UPDATE_DELAY +from country_code_assigner import CountryCodeAssigner +from db import db_read, db_write +from models.bbox import BBox +from models.db.country import Country +from osm_countries import get_osm_countries +from services.state_service import StateService +from utils import retry_exponential + + +class CountryService: + @staticmethod + async def update_db_task(*, task_status=anyio.TASK_STATUS_IGNORED) -> NoReturn: + if (await _should_update_db())[1] > 0: + task_status.started() + started = True + else: + started = False + + while True: + with start_transaction(op='db.update', name=CountryService.update_db_task.__qualname__, sampled=True): + await _update_db() + if not started: + task_status.started() + started = True + await anyio.sleep(COUNTRY_UPDATE_DELAY.total_seconds()) + + @staticmethod + @trace + async def get_all() -> Sequence[Country]: + async with db_read() as session: + stmt = select(Country) + return (await session.scalars(stmt)).all() + + @classmethod + @trace + async def get_intersecting(cls, bbox_or_geom: BBox | Point) -> Sequence[Country]: + geometry = bbox_or_geom.to_polygon() if isinstance(bbox_or_geom, BBox) else bbox_or_geom + geometry_wkt = 'SRID=4326;' + geometry.wkt + + async with db_read() as session: + stmt = select(Country).where(func.ST_Intersects(Country.geometry, func.ST_GeomFromEWKT(geometry_wkt))) + return (await session.scalars(stmt)).all() + + +@trace +async def _should_update_db() -> tuple[bool, float]: + data = await StateService.get('country') + if data is None or data.get('version', 1) < 2: + return True, 0 + + update_timestamp: float = data['update_timestamp'] + update_age = time() - update_timestamp + if update_age > COUNTRY_UPDATE_DELAY.total_seconds(): + return True, update_timestamp + + return False, update_timestamp + + +@retry_exponential(None, start=4) +@trace +async def _update_db() -> None: + update_required, update_timestamp = await _should_update_db() + if not update_required: + return + + logging.info('Updating country database...') + osm_countries = await get_osm_countries() + data_timestamp = osm_countries[0].timestamp if osm_countries else float('-inf') + + if data_timestamp <= update_timestamp: + logging.info('Nothing to update') + return + + if len(osm_countries) < 210: + # suspiciously low number of countries + logging.warning('Not enough countries found: %d', len(osm_countries)) + return + + code_assigner = CountryCodeAssigner() + countries = tuple( + Country( + code=code_assigner.get_unique(c.tags), + names=_get_names(c.tags), + geometry=c.geometry, + label_position=c.representative_point, + ) + for c in osm_countries + ) + + async with db_write() as session: + await session.execute(text(f'TRUNCATE "{Country.__tablename__}" CASCADE')) + session.add_all(countries) + + await StateService.set('country', {'update_timestamp': data_timestamp, 'version': 2}) + + logging.info('Updating country codes') + from services.aed_service import AEDService + + await AEDService.update_country_codes() + logging.info('Country update finished') + + +def _get_names(tags: dict[str, str]) -> dict[str, str]: + names = {} + + for key in ('name:en', 'int_name', 'name'): + default = tags.get(key) + if default: + names['default'] = default + break + + for k, v in tags.items(): + if k.startswith('name:'): + names[k[5:].upper()] = v + + return names diff --git a/services/photo_report_service.py b/services/photo_report_service.py new file mode 100644 index 0000000..9cd31f4 --- /dev/null +++ b/services/photo_report_service.py @@ -0,0 +1,34 @@ +from collections.abc import Sequence + +from sentry_sdk import trace +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert + +from db import db_read, db_write +from models.db.photo_report import PhotoReport +from services.photo_service import PhotoService + + +class PhotoReportService: + @staticmethod + @trace + async def create(photo_id: str) -> bool: + photo = await PhotoService.get_by_id(photo_id) + if photo is None: + return False # photo not found + + async with db_write() as session: + stmt = ( + insert(PhotoReport) + .values({PhotoReport.photo_id: photo_id}) + .on_conflict_do_nothing(index_elements=(PhotoReport.photo_id,)) + ) + await session.execute(stmt) + return True + + @staticmethod + @trace + async def get_recent(count: int = 10) -> Sequence[PhotoReport]: + async with db_read() as session: + stmt = select(PhotoReport).order_by(PhotoReport.created_at.desc()).limit(count) + return (await session.scalars(stmt)).all() diff --git a/states/photo_state.py b/services/photo_service.py similarity index 54% rename from states/photo_state.py rename to services/photo_service.py index 514b1dc..10e834f 100644 --- a/states/photo_state.py +++ b/services/photo_service.py @@ -1,13 +1,48 @@ -import secrets +import logging from io import BytesIO -from time import time from fastapi import UploadFile from PIL import Image, ImageOps from sentry_sdk import trace -from config import IMAGE_LIMIT_PIXELS, IMAGE_MAX_FILE_SIZE, PHOTO_COLLECTION -from models.photo_info import PhotoInfo +from config import IMAGE_LIMIT_PIXELS, IMAGE_MAX_FILE_SIZE +from db import db_read, db_write +from models.db.photo import Photo + + +class PhotoService: + @staticmethod + @trace + async def get_by_id(id: str, *, check_file: bool = True) -> Photo | None: + async with db_read() as session: + photo = await session.get(Photo, id) + + if photo is None: + return None + if check_file and (not await photo.file_path.is_file()): + return None + + return photo + + @staticmethod + @trace + async def upload(node_id: int, user_id: int, file: UploadFile) -> Photo: + photo = Photo( + node_id=node_id, + user_id=user_id, + ) + + img = Image.open(file.file) + img = ImageOps.exif_transpose(img) + img = _resize_image(img) + img_bytes = _optimize_quality(img) + + await photo.file_path.write_bytes(img_bytes) + + async with db_write() as session: + session.add(photo) + + return photo @trace @@ -24,10 +59,9 @@ def _resize_image(img: Image.Image) -> Image.Image: @trace -def _optimize_image(img: Image.Image, format: str = 'WEBP') -> bytes: +def _optimize_quality(img: Image.Image) -> bytes: high, low = 95, 20 bs_step = 5 - best_quality = None best_buffer = None with BytesIO() as buffer: @@ -36,16 +70,14 @@ def _optimize_image(img: Image.Image, format: str = 'WEBP') -> bytes: buffer.seek(0) buffer.truncate() - img.save(buffer, format=format, quality=quality) + img.save(buffer, format='WEBP', quality=quality) size = buffer.tell() - - print(f'[QS] 🅠 Q{quality}: {size / 1024 / 1024:.2f}MB') + logging.debug('Optimizing avatar quality (quick): Q%d -> %.2fMB', quality, size / 1024 / 1024) if size > IMAGE_MAX_FILE_SIZE: high = quality - bs_step else: low = quality + bs_step - best_quality = quality best_buffer = buffer.getvalue() break else: @@ -58,53 +90,14 @@ def _optimize_image(img: Image.Image, format: str = 'WEBP') -> bytes: buffer.seek(0) buffer.truncate() - img.save(buffer, format=format, quality=quality) + img.save(buffer, format='WEBP', quality=quality) size = buffer.tell() - - print(f'[BS] 🅠 Q{quality}: {size / 1024 / 1024:.2f}MB') + logging.debug('Optimizing avatar quality (fine): Q%d -> %.2fMB', quality, size / 1024 / 1024) if size > IMAGE_MAX_FILE_SIZE: high = quality - bs_step else: low = quality + bs_step - best_quality = quality best_buffer = buffer.getvalue() - print(f'🅠 Photo quality: {best_quality}') return best_buffer - - -class PhotoState: - @staticmethod - @trace - async def get_photo_by_id(id: str, *, check_file: bool = True) -> PhotoInfo | None: - doc = await PHOTO_COLLECTION.find_one({'id': id}, projection={'_id': False}) - - if doc is None: - return None - - info = PhotoInfo.model_construct(**doc) - - if check_file and not await info.path.is_file(): - return None - - return info - - @staticmethod - @trace - async def set_photo(node_id: int, user_id: int, file: UploadFile) -> PhotoInfo: - info = PhotoInfo( - id=secrets.token_urlsafe(16), - node_id=str(node_id), - user_id=str(user_id), - timestamp=time(), - ) - - img = Image.open(file.file) - img = ImageOps.exif_transpose(img) - img = _resize_image(img) - img_bytes = _optimize_image(img) - - await info.path.write_bytes(img_bytes) - await PHOTO_COLLECTION.insert_one(info.model_dump()) - return info diff --git a/services/state_service.py b/services/state_service.py new file mode 100644 index 0000000..965ef9f --- /dev/null +++ b/services/state_service.py @@ -0,0 +1,28 @@ +from sentry_sdk import trace +from sqlalchemy.dialects.postgresql import insert + +from db import db_read, db_write +from models.db.state import State + + +class StateService: + @staticmethod + @trace + async def get(key: str) -> dict | None: + async with db_read() as session: + instance = await session.get(State, key) + return instance.data if (instance is not None) else None + + @staticmethod + @trace + async def set(key: str, data: dict) -> None: + async with db_write() as session: + stmt = ( + insert(State) + .values({State.key: key, State.data: data}) + .on_conflict_do_update( + index_elements=(State.key,), + set_={State.data: data}, + ) + ) + await session.execute(stmt) diff --git a/services/worker_service.py b/services/worker_service.py new file mode 100644 index 0000000..71a239c --- /dev/null +++ b/services/worker_service.py @@ -0,0 +1,57 @@ +import fcntl +import os +from typing import Literal + +import anyio +from anyio import Path + +from config import DATA_DIR +from utils import retry_exponential + +_PID_PATH = DATA_DIR / 'worker.pid' +_STATE_PATH = DATA_DIR / 'worker.state' +_LOCK_PATH = DATA_DIR / 'worker.lock' + + +WorkerState = Literal['startup', 'running'] + + +class WorkerService: + is_primary: bool + + @retry_exponential(10) + @staticmethod + async def init() -> 'WorkerService': + self = WorkerService() + self._lock_file = await anyio.open_file(_LOCK_PATH, 'w') + + try: + fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + self.is_primary = True + await _STATE_PATH.write_text('startup') + await _PID_PATH.write_text(str(os.getpid())) + except BlockingIOError: + self.is_primary = False + + while True: + if await _PID_PATH.is_file() and await _STATE_PATH.is_file(): + pid = await _PID_PATH.read_text() + if pid and await Path(f'/proc/{pid}').is_dir(): + break + + await anyio.sleep(0.1) + + return self + + async def set_state(self, state: WorkerState) -> None: + if not self.is_primary: + raise AssertionError('Only the primary worker can set the state') + await _STATE_PATH.write_text(state) + + @retry_exponential(10) + async def get_state(self) -> WorkerState: + return await _STATE_PATH.read_text() + + async def wait_for_state(self, state: WorkerState) -> None: + while await self.get_state() != state: + await anyio.sleep(0.1) diff --git a/shell.nix b/shell.nix index 293b13f..7bfa60e 100644 --- a/shell.nix +++ b/shell.nix @@ -1,9 +1,9 @@ { isDevelopment ? true }: let - # Currently using nixpkgs-23.11-darwin + # Currently using nixpkgs-unstable # Update with `nixpkgs-update` command - pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/03e303468a0b89792bc40c2f3a7cd8a322b66fad.tar.gz") { }; + pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/807c549feabce7eddbf259dbdcec9e0600a0660d.tar.gz") { }; libraries' = with pkgs; [ # Base libraries @@ -29,40 +29,89 @@ let packages' = with pkgs; [ # Base packages wrappedPython + (postgresql_16_jit.withPackages (ps: [ ps.postgis ])) + varnish # Scripts - # -- Misc - (writeShellScriptBin "make-version" '' - sed -i -r "s|VERSION = '([0-9.]+)'|VERSION = '\1.$(date +%y%m%d)'|g" config.py + # -- Alembic + (writeShellScriptBin "alembic-migration" '' + set -e + name=$1 + if [ -z "$name" ]; then + read -p "Database migration name: " name + fi + alembic -c config/alembic.ini revision --autogenerate --message "$name" '') - ] ++ lib.optionals isDevelopment [ - # Development packages - poetry - ruff + (writeShellScriptBin "alembic-upgrade" "alembic -c config/alembic.ini upgrade head") - # Scripts - # -- Docker (dev) + # -- Supervisor (writeShellScriptBin "dev-start" '' - if command -v podman &> /dev/null; then docker() { podman "$@"; } fi - docker compose -f docker-compose.dev.yml up -d + set -e + pid=$(cat data/supervisor/supervisord.pid 2> /dev/null || echo "") + if [ -n "$pid" ] && $(grep -q "supervisord" "/proc/$pid/cmdline" 2> /dev/null); then + echo "Supervisor is already running" + exit 0 + fi + + if [ ! -d data/postgres ]; then + initdb -D data/postgres \ + --no-instructions \ + --locale=C.UTF-8 \ + --encoding=UTF8 \ + --text-search-config=pg_catalog.simple \ + --auth=password \ + --username=postgres \ + --pwfile=<(echo postgres) + fi + + mkdir -p data/supervisor + supervisord -c config/supervisord.conf + echo "Supervisor started" + + echo "Waiting for Postgres to start..." + while ! pg_isready -q -h 127.0.0.1 -t 10; do sleep 0.1; done + echo "Postgres started, running migrations" + alembic-upgrade '') (writeShellScriptBin "dev-stop" '' - if command -v podman &> /dev/null; then docker() { podman "$@"; } fi - docker compose -f docker-compose.dev.yml down + set -e + pid=$(cat data/supervisor/supervisord.pid 2> /dev/null || echo "") + if [ -n "$pid" ] && $(grep -q "supervisord" "/proc/$pid/cmdline" 2> /dev/null); then + kill -INT "$pid" + echo "Supervisor stopping..." + while $(kill -0 "$pid" 2> /dev/null); do sleep 0.1; done + echo "Supervisor stopped" + else + echo "Supervisor is not running" + fi '') - (writeShellScriptBin "dev-logs" '' - if command -v podman &> /dev/null; then docker() { podman "$@"; } fi - docker compose -f docker-compose.dev.yml logs -f + (writeShellScriptBin "dev-restart" '' + set -ex + dev-stop + dev-start '') (writeShellScriptBin "dev-clean" '' + set -e dev-stop - [ -d data/db ] && sudo rm -r data/db + rm -rf data/cache data/postgres + '') + (writeShellScriptBin "dev-logs-postgres" "tail -f data/supervisor/postgres.log") + (writeShellScriptBin "dev-logs-varnish" "tail -f data/supervisor/varnish.log") + + # -- Misc + (writeShellScriptBin "make-version" '' + sed -i -r "s|VERSION = '([0-9.]+)'|VERSION = '\1.$(date +%y%m%d)'|g" config.py '') + ] ++ lib.optionals isDevelopment [ + # Development packages + poetry + ruff + # Scripts # -- Misc (writeShellScriptBin "nixpkgs-update" '' set -e - hash=$(git ls-remote https://github.com/NixOS/nixpkgs nixpkgs-23.11-darwin | cut -f 1) + hash=$(git ls-remote https://github.com/NixOS/nixpkgs nixpkgs-unstable | cut -f 1) sed -i -E "s|/nixpkgs/archive/[0-9a-f]{40}\.tar\.gz|/nixpkgs/archive/$hash.tar.gz|" shell.nix echo "Nixpkgs updated to $hash" '') @@ -74,7 +123,9 @@ let ]; shell' = with pkgs; lib.optionalString isDevelopment '' - [ ! -e .venv/bin/python ] && [ -h .venv/bin/python ] && rm -r .venv + current_python=$(readlink -e .venv/bin/python || echo "") + current_python=''${current_python%/bin/*} + [ "$current_python" != "${wrappedPython}" ] && rm -r .venv echo "Installing Python dependencies" export POETRY_VIRTUALENVS_IN_PROJECT=1 @@ -99,6 +150,6 @@ let ''; in pkgs.mkShell { - buildInputs = libraries' ++ packages'; + buildInputs = packages'; shellHook = shell'; } diff --git a/state_utils.py b/state_utils.py deleted file mode 100644 index 76b6d7f..0000000 --- a/state_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from sentry_sdk import trace - -from config import STATE_COLLECTION - - -@trace -async def get_state_doc(name: str, **kwargs) -> dict | None: - return await STATE_COLLECTION.find_one({'_name': name}, **kwargs) - - -@trace -async def set_state_doc(name: str, doc: dict, **kwargs) -> None: - await STATE_COLLECTION.replace_one({'_name': name}, doc | {'_name': name}, upsert=True, **kwargs) diff --git a/states/aed_state.py b/states/aed_state.py deleted file mode 100644 index c5a346e..0000000 --- a/states/aed_state.py +++ /dev/null @@ -1,347 +0,0 @@ -from collections.abc import Iterable, Sequence -from time import time -from typing import NoReturn - -import anyio -import numpy as np -from anyio import create_task_group -from asyncache import cached -from cachetools import TTLCache -from pymongo import DeleteOne, ReplaceOne, UpdateOne -from sentry_sdk import start_span, start_transaction, trace -from shapely import Point, get_coordinates, points -from shapely.geometry import mapping -from shapely.geometry.base import BaseGeometry -from sklearn.cluster import Birch - -from config import AED_COLLECTION, AED_REBUILD_THRESHOLD, AED_UPDATE_DELAY -from models.aed import AED -from models.aed_group import AEDGroup -from models.bbox import BBox -from models.country import Country -from overpass import query_overpass -from planet_diffs import get_planet_diffs -from state_utils import get_state_doc, set_state_doc -from transaction import Transaction -from utils import retry_exponential -from validators.geometry import geometry_validator - -_AED_QUERY = 'node[emergency=defibrillator];out meta qt;' - - -@trace -async def _should_update_db() -> tuple[bool, float]: - doc = await get_state_doc('aed') - if doc is None or doc.get('version', 1) < 3: - return True, 0 - - update_timestamp: float = doc['update_timestamp'] - update_age = time() - update_timestamp - if update_age > AED_UPDATE_DELAY.total_seconds(): - return True, update_timestamp - - return False, update_timestamp - - -@trace -async def _get_country_code_updates_by_aeds(aeds: Sequence[AED]) -> list[UpdateOne]: - from states.country_state import CountryState - - bulk_write_args = [] - - async def task(aed: AED): - countries = await CountryState.get_countries_within(aed.position) - country_codes = tuple({c.code for c in countries}) - bulk_write_args.append( - UpdateOne( - {'id': aed.id}, - {'$set': {'country_codes': country_codes}}, - ) - ) - - async with create_task_group() as tg: - for aed in aeds: - tg.start_soon(task, aed) - - print(f'📫 Processing {len(aeds)} AEDs in parallel') - - return bulk_write_args - - -@trace -async def _get_country_code_updates_by_countries(aeds: Sequence[AED]) -> list[UpdateOne]: - from states.country_state import CountryState - - aed_codes_map = {aed.id: set() for aed in aeds} - aed_ids = tuple(aed_codes_map) - countries = await CountryState.get_all_countries() - - async def task(country: Country): - async for doc in AED_COLLECTION.find( - { - '$and': [ - {'id': {'$in': aed_ids}}, - {'position': {'$geoIntersects': {'$geometry': mapping(country.geometry)}}}, - ] - } - ): - aed_codes_map[doc['id']].add(country.code) - - async with create_task_group() as tg: - for country in countries: - tg.start_soon(task, country) - - print(f'📫 Processing {len(countries)} countries in parallel') - - return [ - UpdateOne( - {'id': aed.id}, - {'$set': {'country_codes': tuple(aed_codes_map[aed.id])}}, - ) - for aed in aeds - ] - - -@trace -async def _assign_country_codes(aeds: Sequence[AED]) -> None: - if len(aeds) < 200: - bulk_write_args = await _get_country_code_updates_by_aeds(aeds) - else: - bulk_write_args = await _get_country_code_updates_by_countries(aeds) - - if bulk_write_args: - await AED_COLLECTION.bulk_write(bulk_write_args, ordered=False) - - -def _is_defibrillator(tags: dict[str, str]) -> bool: - return tags.get('emergency') == 'defibrillator' - - -def _process_overpass_node(node: dict) -> AED: - tags = node.get('tags', {}) - is_valid = _is_defibrillator(tags) - assert is_valid, 'Unexpected non-defibrillator node' - return AED( - id=node['id'], - position=Point(node['lon'], node['lat']), - country_codes=None, - tags=tags, - version=node['version'], - ) - - -@trace -async def _update_db_snapshot() -> None: - print('🩺 Updating aed database (overpass)...') - elements, data_timestamp = await query_overpass(_AED_QUERY, timeout=3600, must_return=True) - aeds = tuple(_process_overpass_node(e) for e in elements) - insert_many_arg = tuple(aed.model_dump() for aed in aeds) - - async with Transaction() as s: - await AED_COLLECTION.delete_many({}, session=s) - await AED_COLLECTION.insert_many(insert_many_arg, session=s) - await set_state_doc('aed', {'update_timestamp': data_timestamp, 'version': 3}, session=s) - - if aeds: - print('🩺 Updating country codes') - await _assign_country_codes(aeds) - - print(f'🩺 Update complete: ={len(insert_many_arg)}') - - -def _parse_xml_tags(data: dict) -> dict[str, str]: - tags = data.get('tag', []) - return {tag['@k']: tag['@v'] for tag in tags} - - -def _process_action(action: dict) -> Iterable[AED | int]: - def _process_create_or_modify(node: dict) -> AED | int: - node_tags = _parse_xml_tags(node) - node_valid = _is_defibrillator(node_tags) - if node_valid: - return AED( - id=node['@id'], - position=Point(node['@lon'], node['@lat']), - country_codes=None, - tags=node_tags, - version=node['@version'], - ) - else: - return node['@id'] - - def _process_delete(node: dict) -> str: - return node['@id'] - - if action['@type'] in ('create', 'modify'): - return (_process_create_or_modify(node) for node in action['node']) - elif action['@type'] == 'delete': - return (_process_delete(node) for node in action['node']) - else: - raise NotImplementedError(f'Unknown action type: {action["@type"]}') - - -@trace -async def _update_db_diffs(last_update: float) -> None: - print('🩺 Updating aed database (diff)...') - actions, data_timestamp = await get_planet_diffs(last_update) - - if data_timestamp <= last_update: - print('🩺 Nothing to update') - return - - aeds: list[AED] = [] - remove_ids: set[int] = set() - - for action in actions: - for result in _process_action(action): - if isinstance(result, AED): - aeds.append(result) - else: - remove_ids.add(result) - - bulk_write_arg = [ReplaceOne({'id': aed.id}, aed.model_dump(), upsert=True) for aed in aeds] - bulk_write_arg.extend(DeleteOne({'id': remove_id}) for remove_id in remove_ids) - - # keep transaction as short as possible: avoid doing any computation inside - async with Transaction() as s: - await AED_COLLECTION.bulk_write(bulk_write_arg, ordered=True, session=s) - await set_state_doc('aed', {'update_timestamp': data_timestamp, 'version': 3}, session=s) - - if aeds: - print('🩺 Updating country codes') - await _assign_country_codes(aeds) - - print(f'🩺 Update complete: +{len(aeds)} -{len(remove_ids)}') - - -@retry_exponential(None, start=4) -@trace -async def _update_db() -> None: - update_required, update_timestamp = await _should_update_db() - if not update_required: - return - - update_age = time() - update_timestamp - - if update_age > AED_REBUILD_THRESHOLD.total_seconds(): - await _update_db_snapshot() - else: - await _update_db_diffs(update_timestamp) - - -class AEDState: - @staticmethod - async def update_db_task(*, task_status=anyio.TASK_STATUS_IGNORED) -> NoReturn: - if (await _should_update_db())[1] > 0: - task_status.started() - started = True - else: - started = False - - while True: - with start_transaction(op='db.update', name=AEDState.update_db_task.__qualname__): - await _update_db() - if not started: - task_status.started() - started = True - await anyio.sleep(AED_UPDATE_DELAY.total_seconds()) - - @classmethod - @trace - async def update_country_codes(cls) -> None: - await _assign_country_codes(await cls.get_all_aeds()) - - @staticmethod - @cached(TTLCache(maxsize=1024, ttl=3600)) - @trace - async def count_aeds_by_country_code(country_code: str) -> int: - return await AED_COLLECTION.count_documents({'country_codes': country_code}) - - @staticmethod - @trace - async def get_aed_by_id(aed_id: int) -> AED | None: - doc = await AED_COLLECTION.find_one({'id': aed_id}, projection={'_id': False}) - if doc is None: - return None - - doc['id'] = int(doc['id']) - doc['position'] = geometry_validator(doc['position']) - aed = AED.model_construct(**doc) - return aed - - @staticmethod - @trace - async def get_all_aeds(filter: dict | None = None) -> Sequence[AED]: - cursor = AED_COLLECTION.find(filter, projection={'_id': False}) - docs = await cursor.to_list(None) - if not docs: - return () - - coords = tuple(doc['position']['coordinates'] for doc in docs) - positions = points(coords) - result = [None] * len(docs) - - for i, doc, position in zip(range(len(docs)), docs, positions, strict=True): - doc['id'] = int(doc['id']) - doc['position'] = position - aed = AED.model_construct(**doc) - result[i] = aed - - return result - - @classmethod - async def get_aeds_by_country_code(cls, country_code: str) -> Sequence[AED]: - return await cls.get_all_aeds({'country_codes': country_code}) - - @classmethod - async def get_aeds_within_geom(cls, geometry: BaseGeometry, group_eps: float | None) -> Sequence[AED | AEDGroup]: - aeds = await cls.get_all_aeds({'position': {'$geoIntersects': {'$geometry': mapping(geometry)}}}) - - if len(aeds) <= 1 or group_eps is None: - return aeds - - positions = tuple(get_coordinates(aed.position)[0] for aed in aeds) - - # deterministic sampling - max_fit_samples = 7000 - if len(positions) > max_fit_samples: - indices = np.linspace(0, len(positions), max_fit_samples, endpoint=False, dtype=int) - fit_positions = np.asarray(positions)[indices] - else: - fit_positions = positions - - with start_span(description=f'Fitting model with {len(fit_positions)} samples'): - model = Birch(threshold=group_eps, n_clusters=None, compute_labels=False, copy=False) - model.fit(fit_positions) - center_points = points(model.subcluster_centers_) - - with start_span(description=f'Processing {len(aeds)} samples'): - cluster_groups: tuple[list[AED]] = tuple([] for _ in range(len(center_points))) - result: list[AED | AEDGroup] = [] - - with start_span(description='Clustering'): - clusters = model.predict(positions) - - for aed, cluster in zip(aeds, clusters, strict=True): - cluster_groups[cluster].append(aed) - - for group, center_point in zip(cluster_groups, center_points, strict=True): - if len(group) == 0: - continue - if len(group) == 1: - result.append(group[0]) - continue - - result.append( - AEDGroup( - position=center_point, - count=len(group), - access=AEDGroup.decide_access(aed.access for aed in group), - ) - ) - - return result - - @classmethod - async def get_aeds_within_bbox(cls, bbox: BBox, group_eps: float | None) -> Sequence[AED | AEDGroup]: - return await cls.get_aeds_within_geom(bbox.to_polygon(), group_eps) diff --git a/states/country_state.py b/states/country_state.py deleted file mode 100644 index 271a501..0000000 --- a/states/country_state.py +++ /dev/null @@ -1,173 +0,0 @@ -from collections.abc import Sequence -from time import time -from typing import NoReturn - -import anyio -from sentry_sdk import start_transaction, trace -from shapely.geometry import Point, mapping -from shapely.geometry.base import BaseGeometry - -from config import COUNTRY_COLLECTION, COUNTRY_UPDATE_DELAY -from models.bbox import BBox -from models.country import Country -from osm_countries import get_osm_countries -from state_utils import get_state_doc, set_state_doc -from transaction import Transaction -from utils import retry_exponential, simple_point_mapping -from validators.geometry import geometry_validator - - -class CountryCodeAssigner: - def __init__(self): - self.used = set() - - def get_unique(self, tags: dict[str, str]) -> str: - for check_used in (True, False): - for code in ( - tags.get('ISO3166-2'), - tags.get('ISO3166-1'), - tags.get('ISO3166-1:alpha2'), - tags.get('ISO3166-1:alpha3'), - ): - if code and len(code) >= 2 and (not check_used or code not in self.used): - self.used.add(code) - return code - - return 'XX' - - -shape_cache: dict[str, tuple[BaseGeometry, Point]] = {} - - -def _get_names(tags: dict[str, str]) -> dict[str, str]: - names = {} - - for default in ( - tags.get('name:en'), - tags.get('int_name'), - tags.get('name'), - ): - if default: - names['default'] = default - break - - for k, v in tags.items(): - if k.startswith('name:'): - names[k[5:].upper()] = v - - return names - - -@trace -async def _should_update_db() -> tuple[bool, float]: - doc = await get_state_doc('country') - if doc is None or doc.get('version', 1) < 2: - return True, 0 - - update_timestamp: float = doc['update_timestamp'] - update_age = time() - update_timestamp - if update_age > COUNTRY_UPDATE_DELAY.total_seconds(): - return True, update_timestamp - - return False, update_timestamp - - -@retry_exponential(None, start=4) -@trace -async def _update_db() -> None: - update_required, update_timestamp = await _should_update_db() - if not update_required: - return - - print('🗺️ Updating country database...') - osm_countries = await get_osm_countries() - data_timestamp = osm_countries[0].timestamp if osm_countries else float('-inf') - - if data_timestamp <= update_timestamp: - print('🗺️ Nothing to update') - return - - if len(osm_countries) < 210: - # suspiciously low number of countries - print(f'🗺️ Not enough countries found: {len(osm_countries)})') - return - - country_code_assigner = CountryCodeAssigner() - insert_many_arg = [] - - for c in osm_countries: - country = Country( - names=_get_names(c.tags), - code=country_code_assigner.get_unique(c.tags), - geometry=c.geometry, - label_position=c.representative_point, - ) - insert_many_arg.append(country.model_dump()) - - # keep transaction as short as possible: avoid doing any computation inside - async with Transaction() as s: - await COUNTRY_COLLECTION.delete_many({}, session=s) - await COUNTRY_COLLECTION.insert_many(insert_many_arg, session=s) - await set_state_doc('country', {'update_timestamp': data_timestamp, 'version': 2}, session=s) - - shape_cache.clear() - - print('🗺️ Updating country codes') - from states.aed_state import AEDState - - await AEDState.update_country_codes() - - print('🗺️ Update complete') - - -class CountryState: - @staticmethod - async def update_db_task(*, task_status=anyio.TASK_STATUS_IGNORED) -> NoReturn: - if (await _should_update_db())[1] > 0: - task_status.started() - started = True - else: - started = False - - while True: - with start_transaction(op='db.update', name=CountryState.update_db_task.__qualname__, sampled=True): - await _update_db() - if not started: - task_status.started() - started = True - await anyio.sleep(COUNTRY_UPDATE_DELAY.total_seconds()) - - @staticmethod - @trace - async def get_all_countries(filter: dict | None = None) -> Sequence[Country]: - cursor = COUNTRY_COLLECTION.find(filter, projection={'_id': False}) - result = [] - - async for doc in cursor: - cached = shape_cache.get(doc['code']) - if cached is None: - geometry = geometry_validator(doc['geometry']) - label_position = geometry_validator(doc['label_position']) - shape_cache[doc['code']] = (geometry, label_position) - else: - geometry, label_position = cached - - doc['geometry'] = geometry - doc['label_position'] = label_position - result.append(Country.model_construct(**doc)) - - return result - - @classmethod - async def get_countries_within(cls, bbox_or_pos: BBox | Point) -> Sequence[Country]: - return await cls.get_all_countries( - { - 'geometry': { - '$geoIntersects': { - '$geometry': mapping(bbox_or_pos.to_polygon(nodes_per_edge=8)) - if isinstance(bbox_or_pos, BBox) - else simple_point_mapping(bbox_or_pos) - } - } - } - ) diff --git a/states/photo_report_state.py b/states/photo_report_state.py deleted file mode 100644 index c59e8b2..0000000 --- a/states/photo_report_state.py +++ /dev/null @@ -1,47 +0,0 @@ -import secrets -from collections.abc import Sequence -from time import time - -import pymongo -from sentry_sdk import trace - -from config import PHOTO_REPORT_COLLECTION -from models.photo_report import PhotoReport -from states.photo_state import PhotoState - - -class PhotoReportState: - @staticmethod - @trace - async def report_by_photo_id(photo_id: str) -> bool: - photo_info = await PhotoState.get_photo_by_id(photo_id) - - if photo_info is None: - return False # photo not found - - if await PHOTO_REPORT_COLLECTION.find_one({'photo_id': photo_id}, projection={'_id': False}): - return False # already reported - - await PHOTO_REPORT_COLLECTION.insert_one( - PhotoReport( - id=secrets.token_urlsafe(16), - photo_id=photo_id, - timestamp=time(), - ).model_dump() - ) - - return True - - @staticmethod - @trace - async def get_recent_reports(count: int = 10) -> Sequence[PhotoReport]: - cursor = ( - PHOTO_REPORT_COLLECTION.find(projection={'_id': False}).sort('timestamp', pymongo.DESCENDING).limit(count) - ) - - result = [] - - async for doc in cursor: - result.append(PhotoReport.model_construct(**doc)) # noqa: PERF401 - - return tuple(result) diff --git a/states/worker_state.py b/states/worker_state.py deleted file mode 100644 index 227e338..0000000 --- a/states/worker_state.py +++ /dev/null @@ -1,55 +0,0 @@ -import fcntl -import os -from datetime import timedelta -from enum import Enum - -import anyio -from anyio import Path - -from config import NAME -from utils import retry_exponential - -_PID_PATH = Path(f'/tmp/{NAME}-worker.pid') -_STATE_PATH = Path(f'/tmp/{NAME}-worker.state') -_LOCK_PATH = Path(f'/tmp/{NAME}-worker.lock') - - -class WorkerStateEnum(str, Enum): - STARTUP = 'startup' - RUNNING = 'running' - - -class WorkerState: - is_primary: bool - - @retry_exponential(timedelta(seconds=10)) - async def ainit(self) -> None: - self._lock_file = await anyio.open_file(_LOCK_PATH, 'w') - - try: - fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - self.is_primary = True - await _STATE_PATH.write_text(WorkerStateEnum.STARTUP.value) - await _PID_PATH.write_text(str(os.getpid())) - except BlockingIOError: - self.is_primary = False - - while True: - if await _PID_PATH.is_file() and await _STATE_PATH.is_file(): - pid = await _PID_PATH.read_text() - if pid and await Path(f'/proc/{pid}').is_dir(): - break - - await anyio.sleep(0.1) - - async def set_state(self, state: WorkerStateEnum) -> None: - assert self.is_primary - await _STATE_PATH.write_text(state.value) - - @retry_exponential(timedelta(seconds=10)) - async def get_state(self) -> WorkerStateEnum: - return WorkerStateEnum(await _STATE_PATH.read_text()) - - async def wait_for_state(self, state: WorkerStateEnum) -> None: - while await self.get_state() != state: - await anyio.sleep(0.1) diff --git a/transaction.py b/transaction.py deleted file mode 100644 index 3fa34cf..0000000 --- a/transaction.py +++ /dev/null @@ -1,14 +0,0 @@ -from motor.core import AgnosticClientSession - -from config import MONGO_CLIENT - - -class Transaction: - async def __aenter__(self): - self.session: AgnosticClientSession = await MONGO_CLIENT.start_session() - self.context = self.session.start_transaction() - await self.context.__aenter__() - return self.session - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.context.__aexit__(exc_type, exc_val, exc_tb) diff --git a/utils.py b/utils.py index 3eed626..db8771d 100644 --- a/utils.py +++ b/utils.py @@ -1,17 +1,33 @@ import functools +import logging import time -import traceback from datetime import timedelta import anyio -import httpx -from shapely import Point, get_coordinates +import msgspec +from httpx import AsyncClient, Timeout from config import USER_AGENT -def retry_exponential(timeout: timedelta | None, *, start: float = 1): - timeout_seconds = float('inf') if timeout is None else timeout.total_seconds() +def typed_json_decoder(t: type | None) -> msgspec.json.Decoder: + """ + Create a JSON decoder which returns a specific type. + """ + return msgspec.json.Decoder(t) if (t is not None) else msgspec.json.Decoder() + + +JSON_ENCODE = msgspec.json.Encoder(decimal_format='number').encode +JSON_DECODE = typed_json_decoder(None).decode + + +def retry_exponential(timeout: timedelta | float | None, *, start: float = 1): + if timeout is None: + timeout_seconds = float('inf') + elif isinstance(timeout, timedelta): + timeout_seconds = timeout.total_seconds() + else: + timeout_seconds = timeout def decorator(func): @functools.wraps(func) @@ -23,8 +39,7 @@ async def wrapper(*args, **kwargs): try: return await func(*args, **kwargs) except Exception: - print(f'[⛔] {func.__name__} failed') - traceback.print_exc() + logging.warning('%s failed', func.__qualname__, exc_info=True) if (time.perf_counter() + sleep) - ts > timeout_seconds: raise await anyio.sleep(sleep) @@ -35,12 +50,12 @@ async def wrapper(*args, **kwargs): return decorator -def get_http_client(base_url: str = '', *, auth=None) -> httpx.AsyncClient: - return httpx.AsyncClient( +def get_http_client(base_url: str = '', *, auth=None) -> AsyncClient: + return AsyncClient( auth=auth, base_url=base_url, headers={'User-Agent': USER_AGENT}, - timeout=httpx.Timeout(60, connect=15), + timeout=Timeout(60, connect=15), http1=True, http2=True, follow_redirects=True, @@ -51,13 +66,8 @@ def abbreviate(num: int) -> str: for suffix, divisor in (('m', 1_000_000), ('k', 1_000)): if num >= divisor: return f'{num / divisor:.1f}{suffix}' - return str(num) def get_wikimedia_commons_url(path: str) -> str: return f'https://commons.wikimedia.org/wiki/{path}' - - -def simple_point_mapping(point: Point) -> dict: - return {'type': 'Point', 'coordinates': get_coordinates(point)[0].tolist()} diff --git a/validators/geometry.py b/validators/geometry.py deleted file mode 100644 index ff5f2e9..0000000 --- a/validators/geometry.py +++ /dev/null @@ -1,23 +0,0 @@ -from pydantic import PlainSerializer, PlainValidator -from shapely.geometry import mapping, shape -from shapely.ops import BaseGeometry - - -def geometry_validator(value: dict | BaseGeometry) -> BaseGeometry: - """ - Validate a geometry. - """ - - return shape(value) if isinstance(value, dict) else value - - -def geometry_serializer(value: BaseGeometry) -> dict: - """ - Serialize a geometry. - """ - - return mapping(value) - - -GeometryValidator = PlainValidator(geometry_validator) -GeometrySerializer = PlainSerializer(geometry_serializer, return_type=dict) From 1819c2ae4de920d89785bf8c0cc1464fb9c12e50 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 22:25:39 +0200 Subject: [PATCH 02/13] Ensure data dir exists, Cleanup gitignore and /tmp --- .gitignore | 3 --- default.nix | 1 - shell.nix | 1 + 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 30799f5..45c215f 100644 --- a/.gitignore +++ b/.gitignore @@ -201,6 +201,3 @@ pyrightconfig.json data/* cert/* - -cython_lib/*.c -cython_lib/*.html diff --git a/default.nix b/default.nix index d3d7f67..c27e867 100644 --- a/default.nix +++ b/default.nix @@ -44,7 +44,6 @@ with pkgs; dockerTools.buildLayeredImage { extraCommands = '' set -e - mkdir tmp mkdir app && cd app cp "${./.}"/*.py . cp -r "${./.}"/alembic_ . diff --git a/shell.nix b/shell.nix index 7bfa60e..14cf5a2 100644 --- a/shell.nix +++ b/shell.nix @@ -53,6 +53,7 @@ let exit 0 fi + mkdir -p data if [ ! -d data/postgres ]; then initdb -D data/postgres \ --no-instructions \ From ed16d2003866c9cf4e8388bdaf8d231b62263d48 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 22:48:57 +0200 Subject: [PATCH 03/13] Add coreutils package --- config/postgres.conf | 2 +- shell.nix | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/postgres.conf b/config/postgres.conf index e966f9a..b942890 100644 --- a/config/postgres.conf +++ b/config/postgres.conf @@ -39,7 +39,7 @@ checkpoint_warning = 10min # reason: improved performance on expected hardware effective_io_concurrency = 200 maintenance_io_concurrency = 200 -random_page_cost = 2 +random_page_cost = 1.1 # increase logging verbosity # reason: useful for development diff --git a/shell.nix b/shell.nix index 14cf5a2..aa5b92e 100644 --- a/shell.nix +++ b/shell.nix @@ -29,6 +29,7 @@ let packages' = with pkgs; [ # Base packages wrappedPython + coreutils (postgresql_16_jit.withPackages (ps: [ ps.postgis ])) varnish From caf81a8ace8f6b4f3f5d493010a500b63d1c2384 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 22:55:38 +0200 Subject: [PATCH 04/13] Make data dir in docker layer --- default.nix | 1 + shell.nix | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/default.nix b/default.nix index c27e867..dd2fbd8 100644 --- a/default.nix +++ b/default.nix @@ -45,6 +45,7 @@ with pkgs; dockerTools.buildLayeredImage { extraCommands = '' set -e mkdir app && cd app + mkdir data cp "${./.}"/*.py . cp -r "${./.}"/alembic_ . cp -r "${./.}"/api . diff --git a/shell.nix b/shell.nix index aa5b92e..61aa93c 100644 --- a/shell.nix +++ b/shell.nix @@ -54,7 +54,6 @@ let exit 0 fi - mkdir -p data if [ ! -d data/postgres ]; then initdb -D data/postgres \ --no-instructions \ From 395444ec72cd681abbe798a3d38c851bfc8db40b Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 22:58:19 +0200 Subject: [PATCH 05/13] Update volume paths --- default.nix | 4 +++- docker-compose.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/default.nix b/default.nix index dd2fbd8..b20ab0c 100644 --- a/default.nix +++ b/default.nix @@ -45,7 +45,7 @@ with pkgs; dockerTools.buildLayeredImage { extraCommands = '' set -e mkdir app && cd app - mkdir data + mkdir -p data/cache data/postgres data/photos cp "${./.}"/*.py . cp -r "${./.}"/alembic_ . cp -r "${./.}"/api . @@ -75,6 +75,8 @@ with pkgs; dockerTools.buildLayeredImage { "TZ=UTC" ]; Volumes = { + "/app/data/cache" = { }; + "/app/data/postgres" = { }; "/app/data/photos" = { }; }; Entrypoint = [ "${entrypoint}/bin/entrypoint" ]; diff --git a/docker-compose.yml b/docker-compose.yml index d4a53ba..a202422 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,6 @@ services: - ${LISTEN:-80}:80 volumes: - - ./data/db:/app/data/db - ./data/cache:/app/data/cache + - ./data/postgres:/app/data/postgres - /mnt/data/${TAG}/photos:/app/data/photos From eddbab3140f3ddf76cc480e026c2d3a10037dd10 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 23:08:24 +0200 Subject: [PATCH 06/13] More specific postgres data check --- shell.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell.nix b/shell.nix index 61aa93c..d7d3b20 100644 --- a/shell.nix +++ b/shell.nix @@ -54,7 +54,7 @@ let exit 0 fi - if [ ! -d data/postgres ]; then + if [ ! -f data/postgres/PG_VERSION ]; then initdb -D data/postgres \ --no-instructions \ --locale=C.UTF-8 \ From 7db2807456a56df4399916e5c51785190006f1f0 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Tue, 2 Apr 2024 23:14:57 +0200 Subject: [PATCH 07/13] Fix command not found --- default.nix | 4 +--- shell.nix | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/default.nix b/default.nix index b20ab0c..0a0c276 100644 --- a/default.nix +++ b/default.nix @@ -24,11 +24,9 @@ let entrypoint = (pkgs.writeShellScriptBin "entrypoint" '' set -ex dev-start - set -o allexport source "envs/app/${envTag}.env" set - +o allexport - + set +o allexport exec python -m uvicorn main:app "$@" ''); in diff --git a/shell.nix b/shell.nix index d7d3b20..5530012 100644 --- a/shell.nix +++ b/shell.nix @@ -144,7 +144,7 @@ let echo "Loading .env file" set -o allexport source .env set - +o allexport + set +o allexport fi '' + lib.optionalString (!isDevelopment) '' make-version From 4026125ab0b64d9ea1d01cf2b54415e9b48d4b7b Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 00:50:01 +0200 Subject: [PATCH 08/13] Fixup nix paths --- config/supervisord.conf | 16 ------------ default.nix | 4 +-- shell.nix | 55 +++++++++++++++++++++++++++++------------ 3 files changed, 41 insertions(+), 34 deletions(-) delete mode 100644 config/supervisord.conf diff --git a/config/supervisord.conf b/config/supervisord.conf deleted file mode 100644 index 1b41100..0000000 --- a/config/supervisord.conf +++ /dev/null @@ -1,16 +0,0 @@ -[supervisord] -logfile=data/supervisor/supervisord.log -pidfile=data/supervisor/supervisord.pid -strip_ansi=true - -[program:postgres] -command=postgres -c config_file=config/postgres.conf -D data/postgres -stopsignal=INT -stdout_logfile=data/supervisor/postgres.log -stderr_logfile=data/supervisor/postgres.log - -[program:varnish] -command=varnishd -f config/varnish.vcl -s file,data/cache/varnish.bin,2G -stopsignal=INT -stdout_logfile=data/supervisor/varnish.log -stderr_logfile=data/supervisor/varnish.log diff --git a/default.nix b/default.nix index 0a0c276..230ddd7 100644 --- a/default.nix +++ b/default.nix @@ -21,14 +21,14 @@ let pathsToLink = [ "/bin" "/lib" ]; }; - entrypoint = (pkgs.writeShellScriptBin "entrypoint" '' + entrypoint = pkgs.writeShellScriptBin "entrypoint" '' set -ex dev-start set -o allexport source "envs/app/${envTag}.env" set set +o allexport exec python -m uvicorn main:app "$@" - ''); + ''; in with pkgs; dockerTools.buildLayeredImage { name = "backend"; diff --git a/shell.nix b/shell.nix index 5530012..7be8281 100644 --- a/shell.nix +++ b/shell.nix @@ -14,24 +14,50 @@ let ]; # Wrap Python to override LD_LIBRARY_PATH - wrappedPython = with pkgs; (symlinkJoin { + wrappedPython = with pkgs; symlinkJoin { name = "python"; paths = [ # Enable Python optimizations when in production - (if isDevelopment then python312 else python312.override { enableOptimizations = true; }) + # (if isDevelopment then python312 else python312.override { enableOptimizations = true; }) + python312 ]; buildInputs = [ makeWrapper ]; postBuild = '' wrapProgram "$out/bin/python3.12" --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libraries'}" ''; - }); + }; + + postgres' = pkgs.postgresql_16_jit.withPackages (ps: [ ps.postgis ]); + varnish' = pkgs.varnish; + + supervisordConfig = with pkgs; writeTextFile { + name = "supervisord.conf"; + text = '' + [supervisord] + logfile=data/supervisor/supervisord.log + pidfile=data/supervisor/supervisord.pid + strip_ansi=true + + [program:postgres] + command=${postgres'}/bin/postgres -c config_file=config/postgres.conf -D data/postgres + stopsignal=INT + stdout_logfile=data/supervisor/postgres.log + stderr_logfile=data/supervisor/postgres.log + + [program:varnish] + command=${varnish'}/bin/varnishd -f config/varnish.vcl -s file,data/cache/varnish.bin,2G + stopsignal=INT + stdout_logfile=data/supervisor/varnish.log + stderr_logfile=data/supervisor/varnish.log + ''; + }; packages' = with pkgs; [ # Base packages wrappedPython coreutils - (postgresql_16_jit.withPackages (ps: [ ps.postgis ])) - varnish + postgres' + varnish' # Scripts # -- Alembic @@ -55,7 +81,7 @@ let fi if [ ! -f data/postgres/PG_VERSION ]; then - initdb -D data/postgres \ + ${postgres'}/bin/initdb -D data/postgres \ --no-instructions \ --locale=C.UTF-8 \ --encoding=UTF8 \ @@ -66,11 +92,11 @@ let fi mkdir -p data/supervisor - supervisord -c config/supervisord.conf + supervisord -c "${supervisordConfig}" echo "Supervisor started" echo "Waiting for Postgres to start..." - while ! pg_isready -q -h 127.0.0.1 -t 10; do sleep 0.1; done + while ! ${postgres'}/bin/pg_isready -q -h 127.0.0.1 -t 10; do sleep 0.1; done echo "Postgres started, running migrations" alembic-upgrade '') @@ -103,13 +129,6 @@ let (writeShellScriptBin "make-version" '' sed -i -r "s|VERSION = '([0-9.]+)'|VERSION = '\1.$(date +%y%m%d)'|g" config.py '') - ] ++ lib.optionals isDevelopment [ - # Development packages - poetry - ruff - - # Scripts - # -- Misc (writeShellScriptBin "nixpkgs-update" '' set -e hash=$(git ls-remote https://github.com/NixOS/nixpkgs nixpkgs-unstable | cut -f 1) @@ -119,8 +138,12 @@ let (writeShellScriptBin "docker-build" '' set -e if command -v podman &> /dev/null; then docker() { podman "$@"; } fi - docker load < "$(sudo nix-build --no-out-link)" + docker load < "$(nix-build --no-out-link)" '') + ] ++ lib.optionals isDevelopment [ + # Development packages + poetry + ruff ]; shell' = with pkgs; lib.optionalString isDevelopment '' From 906358fde5627ed1cb76f9c9856a3e6d0041d8fc Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 00:52:58 +0200 Subject: [PATCH 09/13] Restore python optimizations --- shell.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shell.nix b/shell.nix index 7be8281..f1c3355 100644 --- a/shell.nix +++ b/shell.nix @@ -18,8 +18,7 @@ let name = "python"; paths = [ # Enable Python optimizations when in production - # (if isDevelopment then python312 else python312.override { enableOptimizations = true; }) - python312 + (if isDevelopment then python312 else python312.override { enableOptimizations = true; }) ]; buildInputs = [ makeWrapper ]; postBuild = '' From 8fada0f0e52c2fde83dc240f431099fd22f21acd Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 14:13:49 +0200 Subject: [PATCH 10/13] Simpler middlewares, Replace varnish with middleware --- api/v1/countries.py | 6 +- api/v1/node.py | 4 +- api/v1/photos.py | 8 +- api/v1/tile.py | 2 +- config.py | 1 + config/redis.conf | 15 + config/supervisord.conf | 16 + config/varnish.vcl | 62 --- db.py | 14 +- default.nix | 4 +- docker-compose.yml | 3 +- main.py | 17 +- middlewares/cache_control_middleware.py | 87 +++++ middlewares/cache_middleware.py | 57 --- middlewares/cache_response_middleware.py | 176 +++++++++ middlewares/compress_middleware.py | 152 ++++++++ middlewares/profiler_middleware.py | 50 ++- middlewares/version_middleware.py | 32 +- models/cached_response.py | 24 ++ poetry.lock | 473 ++++++++++++++++------- pyproject.toml | 1 + shell.nix | 39 +- utils.py | 9 + 23 files changed, 908 insertions(+), 344 deletions(-) create mode 100644 config/redis.conf create mode 100644 config/supervisord.conf delete mode 100644 config/varnish.vcl create mode 100644 middlewares/cache_control_middleware.py delete mode 100644 middlewares/cache_middleware.py create mode 100644 middlewares/cache_response_middleware.py create mode 100644 middlewares/compress_middleware.py create mode 100644 models/cached_response.py diff --git a/api/v1/countries.py b/api/v1/countries.py index 1cf4345..bd2a9e1 100644 --- a/api/v1/countries.py +++ b/api/v1/countries.py @@ -6,7 +6,7 @@ from sentry_sdk import start_span from shapely import get_coordinates -from middlewares.cache_middleware import configure_cache +from middlewares.cache_control_middleware import cache_control from middlewares.skip_serialization import skip_serialization from models.db.country import Country from services.aed_service import AEDService @@ -16,7 +16,7 @@ @router.get('/names') -@configure_cache(timedelta(hours=1), stale=timedelta(days=7)) +@cache_control(timedelta(hours=1), stale=timedelta(days=7)) @skip_serialization() async def get_names(language: str | None = None): countries = await CountryService.get_all() @@ -56,7 +56,7 @@ def limit_country_names(names: dict[str, str]) -> dict[str, str]: @router.get('/{country_code}.geojson') -@configure_cache(timedelta(hours=1), stale=timedelta(seconds=0)) +@cache_control(timedelta(hours=1), stale=timedelta(seconds=0)) @skip_serialization( { 'Content-Disposition': 'attachment', diff --git a/api/v1/node.py b/api/v1/node.py index f2afd8f..3532f99 100644 --- a/api/v1/node.py +++ b/api/v1/node.py @@ -7,7 +7,7 @@ from shapely import get_coordinates from tzfpy import get_tz -from middlewares.cache_middleware import configure_cache +from middlewares.cache_control_middleware import cache_control from middlewares.skip_serialization import skip_serialization from services.aed_service import AEDService from services.photo_service import PhotoService @@ -72,7 +72,7 @@ async def _get_image_data(tags: dict[str, str]) -> dict: @router.get('/node/{node_id}') -@configure_cache(timedelta(minutes=1), stale=timedelta(minutes=5)) +@cache_control(timedelta(minutes=1), stale=timedelta(minutes=5)) @skip_serialization() async def get_node(node_id: int): aed = await AEDService.get_by_id(node_id) diff --git a/api/v1/photos.py b/api/v1/photos.py index 657cc03..7f91b7b 100644 --- a/api/v1/photos.py +++ b/api/v1/photos.py @@ -10,7 +10,7 @@ from feedgen.feed import FeedGenerator from config import IMAGE_CONTENT_TYPES, IMAGE_REMOTE_MAX_FILE_SIZE -from middlewares.cache_middleware import configure_cache +from middlewares.cache_control_middleware import cache_control from openstreetmap import OpenStreetMap, osm_user_has_active_block from osm_change import update_node_tags_osm_change from services.aed_service import AEDService @@ -49,7 +49,7 @@ async def _fetch_image(url: str) -> tuple[bytes, str]: @router.get('/view/{id}.webp') -@configure_cache(timedelta(days=365), stale=timedelta(days=365)) +@cache_control(timedelta(days=365), stale=timedelta(days=365)) async def view(id: str): photo = await PhotoService.get_by_id(id) if photo is None: @@ -59,7 +59,7 @@ async def view(id: str): @router.get('/proxy/direct/{url_encoded:path}') -@configure_cache(timedelta(days=7), stale=timedelta(days=7)) +@cache_control(timedelta(days=7), stale=timedelta(days=7)) async def proxy_direct(url_encoded: str): url = unquote_plus(url_encoded) file, content_type = await _fetch_image(url) @@ -67,7 +67,7 @@ async def proxy_direct(url_encoded: str): @router.get('/proxy/wikimedia-commons/{path_encoded:path}') -@configure_cache(timedelta(days=7), stale=timedelta(days=7)) +@cache_control(timedelta(days=7), stale=timedelta(days=7)) async def proxy_wikimedia_commons(path_encoded: str): meta_url = get_wikimedia_commons_url(unquote_plus(path_encoded)) diff --git a/api/v1/tile.py b/api/v1/tile.py index d26f18a..b5ae331 100644 --- a/api/v1/tile.py +++ b/api/v1/tile.py @@ -20,7 +20,7 @@ TILE_MAX_Z, TILE_MIN_Z, ) -from middlewares.cache_middleware import make_cache_control +from middlewares.cache_control_middleware import make_cache_control from models.bbox import BBox from models.db.aed import AED from models.db.country import Country diff --git a/config.py b/config.py index 001f9c1..c01a6fb 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,7 @@ POSTGRES_LOG = os.getenv('POSTGRES_LOG', '0').strip().lower() in ('1', 'true', 'yes') POSTGRES_URL = 'postgresql+asyncpg://postgres:postgres@127.0.0.1/postgres' +REDIS_URL = os.getenv('REDIS_URL', 'redis://127.0.0.1?protocol=3') DEFAULT_CACHE_MAX_AGE = timedelta(minutes=1) DEFAULT_CACHE_STALE = timedelta(minutes=5) diff --git a/config/redis.conf b/config/redis.conf new file mode 100644 index 0000000..4778136 --- /dev/null +++ b/config/redis.conf @@ -0,0 +1,15 @@ +# ( Low-End Deployment Configuration ) +# =========================== +# Targeted Specification: +# - 2 CPU Threads +# - 2GB RAM +# - 6GB SSD + +# disable persistence +# reason: redis is cache only, use postgres for persistence +save "" +appendonly no + +# limit memory usage +maxmemory 256mb +maxmemory-policy allkeys-lru diff --git a/config/supervisord.conf b/config/supervisord.conf new file mode 100644 index 0000000..e96cc2d --- /dev/null +++ b/config/supervisord.conf @@ -0,0 +1,16 @@ +[supervisord] +logfile=data/supervisor/supervisord.log +pidfile=data/supervisor/supervisord.pid +strip_ansi=true + +[program:postgres] +command=postgres -c config_file=config/postgres.conf -D data/postgres +stopsignal=INT +stdout_logfile=data/supervisor/postgres.log +stderr_logfile=data/supervisor/postgres.log + +[program:redis] +command=redis-server config/redis.conf +stopsignal=INT +stdout_logfile=data/supervisor/redis.log +stderr_logfile=data/supervisor/redis.log diff --git a/config/varnish.vcl b/config/varnish.vcl deleted file mode 100644 index f36ff31..0000000 --- a/config/varnish.vcl +++ /dev/null @@ -1,62 +0,0 @@ -vcl 4.1; - -import std; - -backend default { - .host = "127.0.0.1"; - .port = "8000"; -} - -sub vcl_recv { - # application does not use cookies - unset req.http.Cookie; - - # cache origin-invariant - if (!req.http.Access-Control-Request-Method) { - unset req.http.Origin; - } -} - -sub vcl_backend_response { - # compress responses before storing them - set beresp.do_gzip = true; - - # disable any caching by default - set beresp.ttl = 0s; - set beresp.grace = 0s; - - # handle max-age directive - if (beresp.http.Cache-Control ~ "max-age=") { - set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, ".*max-age=(\d+).*", "\1s"), 0s); - } - - # handle stale-while-revalidate directive - if (beresp.http.Cache-Control ~ "stale-while-revalidate=") { - set beresp.grace = std.duration(regsub(beresp.http.Cache-Control, ".*stale-while-revalidate=(\d+).*", "\1s"), 0s); - } -} - -sub vcl_deliver { - # restore CORS after origin stripping - set resp.http.Access-Control-Allow-Origin = "*"; - - if (obj.hits > 0) { - if (obj.ttl >= 0s) { - set resp.http.X-Cache = "HIT"; - } - else { - set resp.http.Cache-Control = regsub(resp.http.Cache-Control, "max-age=\d+", "max-age=0"); - - if (obj.ttl + obj.grace >= 0s) { - set resp.http.X-Cache = "STALE"; - } - else { - set resp.http.X-Cache = "EXPIRED"; - } - } - } else { - set resp.http.X-Cache = "MISS"; - } - - return (deliver); -} diff --git a/db.py b/db.py index 44b7c16..88cd470 100644 --- a/db.py +++ b/db.py @@ -1,8 +1,9 @@ from contextlib import asynccontextmanager +from redis.asyncio import ConnectionPool, Redis from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from config import POSTGRES_LOG, POSTGRES_URL +from config import POSTGRES_LOG, POSTGRES_URL, REDIS_URL from utils import JSON_DECODE, JSON_ENCODE _db_engine = create_async_engine( @@ -10,7 +11,7 @@ echo=POSTGRES_LOG, echo_pool=POSTGRES_LOG, json_deserializer=JSON_DECODE, - json_serializer=lambda x: JSON_ENCODE(x).decode(), # TODO: is decode needed? + json_serializer=lambda x: JSON_ENCODE(x).decode(), query_cache_size=128, ) @@ -40,3 +41,12 @@ async def db_write(): ) as session: yield session await session.commit() + + +_redis_pool = ConnectionPool().from_url(REDIS_URL) + + +@asynccontextmanager +async def redis(): + async with Redis(connection_pool=_redis_pool) as r: + yield r diff --git a/default.nix b/default.nix index 230ddd7..91c7bfb 100644 --- a/default.nix +++ b/default.nix @@ -43,7 +43,7 @@ with pkgs; dockerTools.buildLayeredImage { extraCommands = '' set -e mkdir app && cd app - mkdir -p data/cache data/postgres data/photos + mkdir -p data/postgres data/photos cp "${./.}"/*.py . cp -r "${./.}"/alembic_ . cp -r "${./.}"/api . @@ -73,10 +73,10 @@ with pkgs; dockerTools.buildLayeredImage { "TZ=UTC" ]; Volumes = { - "/app/data/cache" = { }; "/app/data/postgres" = { }; "/app/data/photos" = { }; }; + Ports = [ "8000" ]; Entrypoint = [ "${entrypoint}/bin/entrypoint" ]; User = "docker:docker"; }; diff --git a/docker-compose.yml b/docker-compose.yml index a202422..58e540d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,9 +18,8 @@ services: ] ports: - - ${LISTEN:-80}:80 + - ${LISTEN:-80}:8000 volumes: - - ./data/cache:/app/data/cache - ./data/postgres:/app/data/postgres - /mnt/data/${TAG}/photos:/app/data/photos diff --git a/main.py b/main.py index d0185a5..759e9d9 100644 --- a/main.py +++ b/main.py @@ -8,9 +8,10 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware -from config import DEFAULT_CACHE_MAX_AGE, DEFAULT_CACHE_STALE from json_response import CustomJSONResponse -from middlewares.cache_middleware import CacheMiddleware +from middlewares.cache_control_middleware import CacheControlMiddleware +from middlewares.cache_response_middleware import CacheResponseMiddleware +from middlewares.compress_middleware import CompressMiddleware from middlewares.profiler_middleware import ProfilerMiddleware from middlewares.version_middleware import VersionMiddleware from services.aed_service import AEDService @@ -38,13 +39,8 @@ async def lifespan(_): app = FastAPI(lifespan=lifespan, default_response_class=CustomJSONResponse) -app.add_middleware(ProfilerMiddleware) -app.add_middleware(VersionMiddleware) -app.add_middleware( - CacheMiddleware, - max_age=DEFAULT_CACHE_MAX_AGE, - stale=DEFAULT_CACHE_STALE, -) +app.add_middleware(CacheControlMiddleware) +app.add_middleware(CacheResponseMiddleware) app.add_middleware( CORSMiddleware, allow_origins=['*'], @@ -52,6 +48,9 @@ async def lifespan(_): allow_methods=['GET'], max_age=int(timedelta(days=1).total_seconds()), ) +app.add_middleware(VersionMiddleware) +app.add_middleware(CompressMiddleware) +app.add_middleware(ProfilerMiddleware) def _make_router(path: str, prefix: str) -> APIRouter: diff --git a/middlewares/cache_control_middleware.py b/middlewares/cache_control_middleware.py new file mode 100644 index 0000000..d371715 --- /dev/null +++ b/middlewares/cache_control_middleware.py @@ -0,0 +1,87 @@ +from contextvars import ContextVar +from datetime import timedelta +from functools import lru_cache, wraps + +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +_cache_context: ContextVar[list[str]] = ContextVar('Cache_context') + + +@lru_cache(128) +def make_cache_control(max_age: timedelta, stale: timedelta): + return f'public, max-age={int(max_age.total_seconds())}, stale-while-revalidate={int(stale.total_seconds())}' + + +def parse_cache_control(header: str) -> tuple[timedelta, timedelta]: + max_age = None + stale = None + + for part in header.split(','): + part = part.strip() + if part.startswith('max-age='): + max_age = timedelta(seconds=int(part[8:])) + elif part.startswith('stale-while-revalidate='): + stale = timedelta(seconds=int(part[23:])) + + if max_age is None or stale is None: + raise NotImplementedError(f'Unsupported Cache-Control header: {header}') + + return max_age, stale + + +class CacheControlMiddleware: + """ + Add Cache-Control header from `@cache_control` decorator. + """ + + __slots__ = ('app',) + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + if scope['method'] not in ('GET', 'HEAD'): + await self.app(scope, receive, send) + return + + async def wrapper(message: Message) -> None: + if message['type'] == 'http.response.start': + status_code: int = message['status'] + + if (200 <= status_code < 300 or status_code == 301) and context: + headers = MutableHeaders(raw=message['headers']) + headers.setdefault('Cache-Control', context[0]) + + await send(message) + + context = [] + token = _cache_context.set(context) + try: + await self.app(scope, receive, wrapper) + finally: + _cache_context.reset(token) + + +def cache_control(max_age: timedelta, stale: timedelta): + """ + Decorator to set the Cache-Control header for an endpoint. + """ + + header = make_cache_control(max_age, stale) + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + context = _cache_context.get(None) + if context is not None: + context.append(header) + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/middlewares/cache_middleware.py b/middlewares/cache_middleware.py deleted file mode 100644 index b58daa4..0000000 --- a/middlewares/cache_middleware.py +++ /dev/null @@ -1,57 +0,0 @@ -import functools -from contextvars import ContextVar -from datetime import timedelta - -from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.types import ASGIApp - -_request_context = ContextVar('Request_context') - - -def make_cache_control(max_age: timedelta, stale: timedelta): - return f'public, max-age={int(max_age.total_seconds())}, stale-while-revalidate={int(stale.total_seconds())}' - - -class CacheMiddleware(BaseHTTPMiddleware): - def __init__(self, app: ASGIApp, max_age: timedelta, stale: timedelta): - super().__init__(app) - self.max_age = max_age - self.stale = stale - - async def dispatch(self, request: Request, call_next): - token = _request_context.set(request) - try: - response = await call_next(request) - finally: - _request_context.reset(token) - - if request.method in ('GET', 'HEAD') and 200 <= response.status_code < 300: - try: - max_age = request.state.max_age - except AttributeError: - max_age = self.max_age - - try: - stale = request.state.stale - except AttributeError: - stale = self.stale - - if 'Cache-Control' not in response.headers: - response.headers['Cache-Control'] = make_cache_control(max_age, stale) - - return response - - -def configure_cache(max_age: timedelta, stale: timedelta): - def decorator(func): - @functools.wraps(func) - async def wrapper(*args, **kwargs): - request: Request = _request_context.get() - request.state.max_age = max_age - request.state.stale = stale - return await func(*args, **kwargs) - - return wrapper - - return decorator diff --git a/middlewares/cache_response_middleware.py b/middlewares/cache_response_middleware.py new file mode 100644 index 0000000..defa8e2 --- /dev/null +++ b/middlewares/cache_response_middleware.py @@ -0,0 +1,176 @@ +import logging +from datetime import UTC, datetime, timedelta +from io import BytesIO + +from sentry_sdk import trace +from starlette.datastructures import URL, MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from zstandard import ZstdCompressor, ZstdDecompressor + +from db import redis +from middlewares.cache_control_middleware import make_cache_control, parse_cache_control +from models.cached_response import CachedResponse + +_compress = ZstdCompressor(level=1).compress +_decompress = ZstdDecompressor().decompress + + +class CacheResponseMiddleware: + """ + Cache responses based on Cache-Control header. + """ + + __slots__ = ('app',) + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + if scope['method'] not in ('GET', 'HEAD'): + await self.app(scope, receive, send) + return + + url = URL(scope=scope) + cached = await _get_cached_response(url) + if cached is not None: + if await _deliver_cached_response(cached, send): + # served fresh response + return + else: + # served stale response, refresh cache + send = None + + await CachingResponder(self.app, url)(scope, receive, send) + + +async def _deliver_cached_response(cached: CachedResponse, send: Send) -> bool: + now = datetime.now(UTC) + headers = MutableHeaders(raw=cached.headers) + headers['Age'] = str(int((now - cached.date).total_seconds())) + + if now < (cached.date + cached.max_age): + headers['X-Cache'] = 'HIT' + await send( + { + 'type': 'http.response.start', + 'status': cached.status_code, + 'headers': headers.raw, + } + ) + await send( + { + 'type': 'http.response.body', + 'body': cached.content, + } + ) + return True + + else: + headers['Cache-Control'] = make_cache_control(max_age=timedelta(), stale=cached.stale) + headers['X-Cache'] = 'STALE' + await send( + { + 'type': 'http.response.start', + 'status': cached.status_code, + 'headers': headers.raw, + } + ) + await send( + { + 'type': 'http.response.body', + 'body': cached.content, + } + ) + return False + + +class CachingResponder: + __slots__ = ('app', 'url', 'send', 'cached', 'body_buffer') + + def __init__(self, app: ASGIApp, url: URL) -> None: + self.app = app + self.url = url + self.send: Send | None = None + self.cached: CachedResponse | None = None + self.body_buffer: BytesIO = BytesIO() + + async def __call__(self, scope: Scope, receive: Receive, send: Send | None) -> None: + self.send = send + await self.app(scope, receive, self.wrapper) + + async def wrapper(self, message: Message) -> None: + if self.send is not None: + await self.send(message) + + message_type: str = message['type'] + if message_type == 'http.response.start': + self.satisfy_response_start(message) + return + + # skip if not satisfied + if self.cached is None: + return + + # skip unknown messages + if message_type != 'http.response.body': + logging.warning('Unsupported ASGI message type %r', message_type) + return + + body: bytes = message.get('body', b'') + more_body: bool = message.get('more_body', False) + + self.body_buffer.write(body) + + if not more_body: + self.cached.content = self.body_buffer.getvalue() + self.body_buffer.close() + await _set_cached_response(self.url, self.cached) + + def satisfy_response_start(self, message: Message) -> None: + headers = MutableHeaders(raw=message['headers']) + cache_control: str | None = headers.get('Cache-Control') + if not cache_control: + return + + headers['Age'] = '0' + headers['X-Cache'] = 'MISS' + max_age, stale = parse_cache_control(cache_control) + + self.cached = CachedResponse( + date=datetime.now(UTC), + max_age=max_age, + stale=stale, + status_code=message['status'], + headers=headers.raw, + content=b'', + ) + + +@trace +async def _get_cached_response(url: URL) -> CachedResponse | None: + key = f'cache:{url.path}:{url.query}' + + async with redis() as conn: + value: bytes | None = await conn.get(key) + + if value is None: + return None + + logging.debug('Found cached response for %r', key) + return CachedResponse.from_bytes(_decompress(value)) + + +@trace +async def _set_cached_response(url: URL, cached: CachedResponse) -> None: + key = f'cache:{url.path}:{url.query}' + value = _compress(cached.to_bytes()) + ttl = int((cached.max_age + cached.stale).total_seconds()) + + logging.debug('Caching response for %r', key) + + async with redis() as conn: + await conn.set(key, value, ex=ttl, nx=True) diff --git a/middlewares/compress_middleware.py b/middlewares/compress_middleware.py new file mode 100644 index 0000000..817f9bf --- /dev/null +++ b/middlewares/compress_middleware.py @@ -0,0 +1,152 @@ +import gzip +import logging +import re +from functools import lru_cache +from io import BytesIO + +from starlette.datastructures import Headers, MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding +_accept_encoding_re = re.compile(r'[a-z]{2,8}') + + +@lru_cache(maxsize=128) +def parse_accept_encoding(accept_encoding: str) -> frozenset[str]: + """ + Parse the accept encoding header. + + Returns a set of encodings. + + >>> _parse_accept_encoding('br;q=1.0, gzip;q=0.8, *;q=0.1') + {'br', 'gzip'} + """ + return frozenset(_accept_encoding_re.findall(accept_encoding)) + + +def _is_response_start_satisfied(message: Message) -> bool: + status_code: int = message['status'] + if status_code != 200: + return False + headers = Headers(raw=message['headers']) + if 'Content-Encoding' in headers: + return False + content_type: str | None = headers.get('Content-Type') + if (content_type is not None) and not content_type.startswith(('text/', 'application/')): + return False + return True + + +def _is_response_body_satisfied(body: bytes, more_body: bool) -> bool: + return more_body or len(body) >= 1024 + + +class CompressMiddleware: + """ + Response compressing middleware. + """ + + __slots__ = ('app',) + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + request_headers = Headers(scope=scope) + accept_encoding = request_headers.get('Accept-Encoding') + + if not accept_encoding: + await self.app(scope, receive, send) + return + + accept_encodings = parse_accept_encoding(accept_encoding) + + if 'gzip' in accept_encodings: + await GZipResponder(self.app)(scope, receive, send) + else: + await self.app(scope, receive, send) + + +class GZipResponder: + __slots__ = ('app', 'send', 'initial_message', 'gzip_compressor', 'gzip_buffer') + + def __init__(self, app: ASGIApp) -> None: + self.app = app + self.send: Send = None + self.initial_message: Message | None = None + self.gzip_compressor: gzip.GzipFile | None = None + self.gzip_buffer: BytesIO | None = None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + self.send = send + await self.app(scope, receive, self.wrapper) + + async def wrapper(self, message: Message) -> None: + message_type: str = message['type'] + if message_type == 'http.response.start': + if _is_response_start_satisfied(message): + # delay initial message until response body is satisfied + self.initial_message = message + else: + await self.send(message) + return + + # skip further processing if not satisfied + if self.initial_message is None: + await self.send(message) + return + + # skip unknown messages + if message_type != 'http.response.body': + logging.warning('Unsupported ASGI message type %r', message_type) + await self.send(message) + return + + body: bytes = message.get('body', b'') + more_body: bool = message.get('more_body', False) + + if self.gzip_compressor is None: + if not _is_response_body_satisfied(body, more_body): + await self.send(self.initial_message) + await self.send(message) + self.initial_message = None # skip further processing + return + + headers = MutableHeaders(raw=self.initial_message['headers']) + headers['Content-Encoding'] = 'gzip' + headers.add_vary_header('Accept-Encoding') + + if not more_body: + # one-shot + compressed_body = gzip.compress(body, compresslevel=3) + headers['Content-Length'] = str(len(compressed_body)) + message['body'] = compressed_body + await self.send(self.initial_message) + await self.send(message) + return + + # streaming + del headers['Content-Length'] + await self.send(self.initial_message) + self.gzip_buffer = gzip_buffer = BytesIO() + self.gzip_compressor = gzip.GzipFile(mode='wb', fileobj=gzip_buffer, compresslevel=3) + else: + # read property once for performance + gzip_buffer = self.gzip_buffer + + # streaming + self.gzip_compressor.write(body) + if not more_body: + self.gzip_compressor.close() + compressed_body = gzip_buffer.getvalue() + if more_body: + if compressed_body: + gzip_buffer.seek(0) + gzip_buffer.truncate() + else: + return + await self.send({'type': 'http.response.body', 'body': compressed_body, 'more_body': more_body}) diff --git a/middlewares/profiler_middleware.py b/middlewares/profiler_middleware.py index 7aa2a36..64b24a3 100644 --- a/middlewares/profiler_middleware.py +++ b/middlewares/profiler_middleware.py @@ -1,18 +1,42 @@ -from fastapi import Request from pyinstrument import Profiler -from starlette.middleware.base import BaseHTTPMiddleware +from starlette.datastructures import QueryParams from starlette.responses import HTMLResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send -class ProfilerMiddleware(BaseHTTPMiddleware): - # https://pyinstrument.readthedocs.io/en/latest/guide.html#profile-a-web-request-in-fastapi - async def dispatch(self, request: Request, call_next): - profiling = request.query_params.get('profile', False) - if profiling: - profiler = Profiler() - profiler.start() - await call_next(request) +# https://pyinstrument.readthedocs.io/en/latest/guide.html#profile-a-web-request-in-fastapi +class ProfilerMiddleware: + """ + Request profiling middleware. + + Simply add `profile=1` to the query params. + """ + + __slots__ = ('app',) + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + query_params = QueryParams(scope['query_string']) + + if 'profile' not in query_params: + await self.app(scope, receive, send) + return + + profiler = Profiler() + profiler.start() + + async def wrapper(message: Message) -> None: + if message['type'] != 'http.response.start': + return + profiler.stop() - return HTMLResponse(profiler.output_html()) - else: - return await call_next(request) + response = HTMLResponse(profiler.output_html()) + await response(scope, receive, send) + + await self.app(scope, receive, wrapper) diff --git a/middlewares/version_middleware.py b/middlewares/version_middleware.py index 47564e8..d9fbdb5 100644 --- a/middlewares/version_middleware.py +++ b/middlewares/version_middleware.py @@ -1,11 +1,29 @@ -from fastapi import Request -from starlette.middleware.base import BaseHTTPMiddleware +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send from config import VERSION -class VersionMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - response = await call_next(request) - response.headers['X-Version'] = VERSION - return response +class VersionMiddleware: + """ + Add X-Version header to responses. + """ + + __slots__ = ('app',) + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + async def wrapper(message: Message) -> None: + if message['type'] == 'http.response.start': + headers = MutableHeaders(raw=message['headers']) + headers['X-Version'] = VERSION + + await send(message) + + await self.app(scope, receive, wrapper) diff --git a/models/cached_response.py b/models/cached_response.py new file mode 100644 index 0000000..31a0b1c --- /dev/null +++ b/models/cached_response.py @@ -0,0 +1,24 @@ +from datetime import datetime, timedelta + +from msgspec import Struct + +from utils import MSGPACK_ENCODE, typed_msgpack_decoder + + +class CachedResponse(Struct, forbid_unknown_fields=True, array_like=True): + date: datetime + max_age: timedelta + stale: timedelta + status_code: int + headers: list[tuple[bytes, bytes]] + content: bytes + + def to_bytes(self) -> bytes: + return MSGPACK_ENCODE(self) + + @classmethod + def from_bytes(cls, buffer: bytes) -> 'CachedResponse': + return _decode(buffer) + + +_decode = typed_msgpack_decoder(CachedResponse).decode diff --git a/poetry.lock b/poetry.lock index a1f16b0..ce83f51 100644 --- a/poetry.lock +++ b/poetry.lock @@ -680,6 +680,124 @@ files = [ hpack = ">=4.0,<5" hyperframe = ">=6.0,<7" +[[package]] +name = "hiredis" +version = "2.3.2" +description = "Python wrapper for hiredis" +optional = false +python-versions = ">=3.7" +files = [ + {file = "hiredis-2.3.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:742093f33d374098aa21c1696ac6e4874b52658c870513a297a89265a4d08fe5"}, + {file = "hiredis-2.3.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:9e14fb70ca4f7efa924f508975199353bf653f452e4ef0a1e47549e208f943d7"}, + {file = "hiredis-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d7302b4b17fcc1cc727ce84ded7f6be4655701e8d58744f73b09cb9ed2b13df"}, + {file = "hiredis-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed63e8b75c193c5e5a8288d9d7b011da076cc314fafc3bfd59ec1d8a750d48c8"}, + {file = "hiredis-2.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b4edee59dc089bc3948f4f6fba309f51aa2ccce63902364900aa0a553a85e97"}, + {file = "hiredis-2.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6481c3b7673a86276220140456c2a6fbfe8d1fb5c613b4728293c8634134824"}, + {file = "hiredis-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684840b014ce83541a087fcf2d48227196576f56ae3e944d4dfe14c0a3e0ccb7"}, + {file = "hiredis-2.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c4c0bcf786f0eac9593367b6279e9b89534e008edbf116dcd0de956524702c8"}, + {file = "hiredis-2.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66ab949424ac6504d823cba45c4c4854af5c59306a1531edb43b4dd22e17c102"}, + {file = "hiredis-2.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:322c668ee1c12d6c5750a4b1057e6b4feee2a75b3d25d630922a463cfe5e7478"}, + {file = "hiredis-2.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:bfa73e3f163c6e8b2ec26f22285d717a5f77ab2120c97a2605d8f48b26950dac"}, + {file = "hiredis-2.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:7f39f28ffc65de577c3bc0c7615f149e35bc927802a0f56e612db9b530f316f9"}, + {file = "hiredis-2.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:55ce31bf4711da879b96d511208efb65a6165da4ba91cb3a96d86d5a8d9d23e6"}, + {file = "hiredis-2.3.2-cp310-cp310-win32.whl", hash = "sha256:3dd63d0bbbe75797b743f35d37a4cca7ca7ba35423a0de742ae2985752f20c6d"}, + {file = "hiredis-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:ea002656a8d974daaf6089863ab0a306962c8b715db6b10879f98b781a2a5bf5"}, + {file = "hiredis-2.3.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:adfbf2e9c38b77d0db2fb32c3bdaea638fa76b4e75847283cd707521ad2475ef"}, + {file = "hiredis-2.3.2-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:80b02d27864ebaf9b153d4b99015342382eeaed651f5591ce6f07e840307c56d"}, + {file = "hiredis-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd40d2e2f82a483de0d0a6dfd8c3895a02e55e5c9949610ecbded18188fd0a56"}, + {file = "hiredis-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfa904045d7cebfb0f01dad51352551cce1d873d7c3f80c7ded7d42f8cac8f89"}, + {file = "hiredis-2.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28bd184b33e0dd6d65816c16521a4ba1ffbe9ff07d66873c42ea4049a62fed83"}, + {file = "hiredis-2.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f70481213373d44614148f0f2e38e7905be3f021902ae5167289413196de4ba4"}, + {file = "hiredis-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8797b528c1ff81eef06713623562b36db3dafa106b59f83a6468df788ff0d1"}, + {file = "hiredis-2.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02fc71c8333586871602db4774d3a3e403b4ccf6446dc4603ec12df563127cee"}, + {file = "hiredis-2.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0da56915bda1e0a49157191b54d3e27689b70960f0685fdd5c415dacdee2fbed"}, + {file = "hiredis-2.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e2674a5a3168349435b08fa0b82998ed2536eb9acccf7087efe26e4cd088a525"}, + {file = "hiredis-2.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:dc1c3fd49930494a67dcec37d0558d99d84eca8eb3f03b17198424538f2608d7"}, + {file = "hiredis-2.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:14c7b43205e515f538a9defb4e411e0f0576caaeeda76bb9993ed505486f7562"}, + {file = "hiredis-2.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7bac7e02915b970c3723a7a7c5df4ba7a11a3426d2a3f181e041aa506a1ff028"}, + {file = "hiredis-2.3.2-cp311-cp311-win32.whl", hash = "sha256:63a090761ddc3c1f7db5e67aa4e247b4b3bb9890080bdcdadd1b5200b8b89ac4"}, + {file = "hiredis-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:70d226ab0306a5b8d408235cabe51d4bf3554c9e8a72d53ce0b3c5c84cf78881"}, + {file = "hiredis-2.3.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5c614552c6bd1d0d907f448f75550f6b24fb56cbfce80c094908b7990cad9702"}, + {file = "hiredis-2.3.2-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9c431431abf55b64347ddc8df68b3ef840269cb0aa5bc2d26ad9506eb4b1b866"}, + {file = "hiredis-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a45857e87e9d2b005e81ddac9d815a33efd26ec67032c366629f023fe64fb415"}, + {file = "hiredis-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e138d141ec5a6ec800b6d01ddc3e5561ce1c940215e0eb9960876bfde7186aae"}, + {file = "hiredis-2.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:387f655444d912a963ab68abf64bf6e178a13c8e4aa945cb27388fd01a02e6f1"}, + {file = "hiredis-2.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4852f4bf88f0e2d9bdf91279892f5740ed22ae368335a37a52b92a5c88691140"}, + {file = "hiredis-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d711c107e83117129b7f8bd08e9820c43ceec6204fff072a001fd82f6d13db9f"}, + {file = "hiredis-2.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92830c16885f29163e1c2da1f3c1edb226df1210ec7e8711aaabba3dd0d5470a"}, + {file = "hiredis-2.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:16b01d9ceae265d4ab9547be0cd628ecaff14b3360357a9d30c029e5ae8b7e7f"}, + {file = "hiredis-2.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5986fb5f380169270a0293bebebd95466a1c85010b4f1afc2727e4d17c452512"}, + {file = "hiredis-2.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:49532d7939cc51f8e99efc326090c54acf5437ed88b9c904cc8015b3c4eda9c9"}, + {file = "hiredis-2.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8f34801b251ca43ad70691fb08b606a2e55f06b9c9fb1fc18fd9402b19d70f7b"}, + {file = "hiredis-2.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7298562a49d95570ab1c7fc4051e72824c6a80e907993a21a41ba204223e7334"}, + {file = "hiredis-2.3.2-cp312-cp312-win32.whl", hash = "sha256:e1d86b75de787481b04d112067a4033e1ecfda2a060e50318a74e4e1c9b2948c"}, + {file = "hiredis-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:6dbfe1887ffa5cf3030451a56a8f965a9da2fa82b7149357752b67a335a05fc6"}, + {file = "hiredis-2.3.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:4fc242e9da4af48714199216eb535b61e8f8d66552c8819e33fc7806bd465a09"}, + {file = "hiredis-2.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e81aa4e9a1fcf604c8c4b51aa5d258e195a6ba81efe1da82dea3204443eba01c"}, + {file = "hiredis-2.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419780f8583ddb544ffa86f9d44a7fcc183cd826101af4e5ffe535b6765f5f6b"}, + {file = "hiredis-2.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6871306d8b98a15e53a5f289ec1106a3a1d43e7ab6f4d785f95fcef9a7bd9504"}, + {file = "hiredis-2.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb0b35b63717ef1e41d62f4f8717166f7c6245064957907cfe177cc144357c"}, + {file = "hiredis-2.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c490191fa1218851f8a80c5a21a05a6f680ac5aebc2e688b71cbfe592f8fec6"}, + {file = "hiredis-2.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:4baf4b579b108062e91bd2a991dc98b9dc3dc06e6288db2d98895eea8acbac22"}, + {file = "hiredis-2.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e627d8ef5e100556e09fb44c9571a432b10e11596d3c4043500080ca9944a91a"}, + {file = "hiredis-2.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:ba3dc0af0def8c21ce7d903c59ea1e8ec4cb073f25ece9edaec7f92a286cd219"}, + {file = "hiredis-2.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:56e9b7d6051688ca94e68c0c8a54a243f8db841911b683cedf89a29d4de91509"}, + {file = "hiredis-2.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:380e029bb4b1d34cf560fcc8950bf6b57c2ef0c9c8b7c7ac20b7c524a730fadd"}, + {file = "hiredis-2.3.2-cp37-cp37m-win32.whl", hash = "sha256:948d9f2ca7841794dd9b204644963a4bcd69ced4e959b0d4ecf1b8ce994a6daa"}, + {file = "hiredis-2.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:cfa67afe2269b2d203cd1389c00c5bc35a287cd57860441fb0e53b371ea6a029"}, + {file = "hiredis-2.3.2-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:bcbe47da0aebc00a7cfe3ebdcff0373b86ce2b1856251c003e3d69c9db44b5a7"}, + {file = "hiredis-2.3.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f2c9c0d910dd3f7df92f0638e7f65d8edd7f442203caf89c62fc79f11b0b73f8"}, + {file = "hiredis-2.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:01b6c24c0840ac7afafbc4db236fd55f56a9a0919a215c25a238f051781f4772"}, + {file = "hiredis-2.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1f567489f422d40c21e53212a73bef4638d9f21043848150f8544ef1f3a6ad1"}, + {file = "hiredis-2.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28adecb308293e705e44087a1c2d557a816f032430d8a2a9bb7873902a1c6d48"}, + {file = "hiredis-2.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27e9619847e9dc70b14b1ad2d0fb4889e7ca18996585c3463cff6c951fd6b10b"}, + {file = "hiredis-2.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a0026cfbf29f07649b0e34509091a2a6016ff8844b127de150efce1c3aff60b"}, + {file = "hiredis-2.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9de7586522e5da6bee83c9cf0dcccac0857a43249cb4d721a2e312d98a684d1"}, + {file = "hiredis-2.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e58494f282215fc461b06709e9a195a24c12ba09570f25bdf9efb036acc05101"}, + {file = "hiredis-2.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3a32b4b76d46f1eb42b24a918d51d8ca52411a381748196241d59a895f7c5c"}, + {file = "hiredis-2.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1979334ccab21a49c544cd1b8d784ffb2747f99a51cb0bd0976eebb517628382"}, + {file = "hiredis-2.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0c0773266e1c38a06e7593bd08870ac1503f5f0ce0f5c63f2b4134b090b5d6a4"}, + {file = "hiredis-2.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bd1cee053416183adcc8e6134704c46c60c3f66b8faaf9e65bf76191ca59a2f7"}, + {file = "hiredis-2.3.2-cp38-cp38-win32.whl", hash = "sha256:5341ce3d01ef3c7418a72e370bf028c7aeb16895e79e115fe4c954fff990489e"}, + {file = "hiredis-2.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:8fc7197ff33047ce43a67851ccf190acb5b05c52fd4a001bb55766358f04da68"}, + {file = "hiredis-2.3.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:f47775e27388b58ce52f4f972f80e45b13c65113e9e6b6bf60148f893871dc9b"}, + {file = "hiredis-2.3.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:9412a06b8a8e09abd6313d96864b6d7713c6003a365995a5c70cfb9209df1570"}, + {file = "hiredis-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3020b60e3fc96d08c2a9b011f1c2e2a6bdcc09cb55df93c509b88be5cb791df"}, + {file = "hiredis-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53d0f2c59bce399b8010a21bc779b4f8c32d0f582b2284ac8c98dc7578b27bc4"}, + {file = "hiredis-2.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57c0d0c7e308ed5280a4900d4468bbfec51f0e1b4cde1deae7d4e639bc6b7766"}, + {file = "hiredis-2.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d63318ca189fddc7e75f6a4af8eae9c0545863619fb38cfba5f43e81280b286"}, + {file = "hiredis-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e741ffe4e2db78a1b9dd6e5d29678ce37fbaaf65dfe132e5b82a794413302ef1"}, + {file = "hiredis-2.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb98038ccd368e0d88bd92ee575c58cfaf33e77f788c36b2a89a84ee1936dc6b"}, + {file = "hiredis-2.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:eae62ed60d53b3561148bcd8c2383e430af38c0deab9f2dd15f8874888ffd26f"}, + {file = "hiredis-2.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ca33c175c1cf60222d9c6d01c38fc17ec3a484f32294af781de30226b003e00f"}, + {file = "hiredis-2.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c5f6972d2bdee3cd301d5c5438e31195cf1cabf6fd9274491674d4ceb46914d"}, + {file = "hiredis-2.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a6b54dabfaa5dbaa92f796f0c32819b4636e66aa8e9106c3d421624bd2a2d676"}, + {file = "hiredis-2.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e96cd35df012a17c87ae276196ea8f215e77d6eeca90709eb03999e2d5e3fd8a"}, + {file = "hiredis-2.3.2-cp39-cp39-win32.whl", hash = "sha256:63b99b5ea9fe4f21469fb06a16ca5244307678636f11917359e3223aaeca0b67"}, + {file = "hiredis-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a50c8af811b35b8a43b1590cf890b61ff2233225257a3cad32f43b3ec7ff1b9f"}, + {file = "hiredis-2.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e8bf4444b09419b77ce671088db9f875b26720b5872d97778e2545cd87dba4a"}, + {file = "hiredis-2.3.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bd42d0d45ea47a2f96babd82a659fbc60612ab9423a68e4a8191e538b85542a"}, + {file = "hiredis-2.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80441b55edbef868e2563842f5030982b04349408396e5ac2b32025fb06b5212"}, + {file = "hiredis-2.3.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec444ab8f27562a363672d6a7372bc0700a1bdc9764563c57c5f9efa0e592b5f"}, + {file = "hiredis-2.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f9f606e810858207d4b4287b4ef0dc622c2aa469548bf02b59dcc616f134f811"}, + {file = "hiredis-2.3.2-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c3dde4ca00fe9eee3b76209711f1941bb86db42b8a75d7f2249ff9dfc026ab0e"}, + {file = "hiredis-2.3.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4dd676107a1d3c724a56a9d9db38166ad4cf44f924ee701414751bd18a784a0"}, + {file = "hiredis-2.3.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce42649e2676ad783186264d5ffc788a7612ecd7f9effb62d51c30d413a3eefe"}, + {file = "hiredis-2.3.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e3f8b1733078ac663dad57e20060e16389a60ab542f18a97931f3a2a2dd64a4"}, + {file = "hiredis-2.3.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:532a84a82156a82529ec401d1c25d677c6543c791e54a263aa139541c363995f"}, + {file = "hiredis-2.3.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d59f88c4daa36b8c38e59ac7bffed6f5d7f68eaccad471484bf587b28ccc478"}, + {file = "hiredis-2.3.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91a14dd95e24dc078204b18b0199226ee44644974c645dc54ee7b00c3157330"}, + {file = "hiredis-2.3.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb777a38797c8c7df0444533119570be18d1a4ce5478dffc00c875684df7bfcb"}, + {file = "hiredis-2.3.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d47c915897a99d0d34a39fad4be97b4b709ab3d0d3b779ebccf2b6024a8c681e"}, + {file = "hiredis-2.3.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:333b5e04866758b11bda5f5315b4e671d15755fc6ed3b7969721bc6311d0ee36"}, + {file = "hiredis-2.3.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c8937f1100435698c18e4da086968c4b5d70e86ea718376f833475ab3277c9aa"}, + {file = "hiredis-2.3.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa45f7d771094b8145af10db74704ab0f698adb682fbf3721d8090f90e42cc49"}, + {file = "hiredis-2.3.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33d5ebc93c39aed4b5bc769f8ce0819bc50e74bb95d57a35f838f1c4378978e0"}, + {file = "hiredis-2.3.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a797d8c7df9944314d309b0d9e1b354e2fa4430a05bb7604da13b6ad291bf959"}, + {file = "hiredis-2.3.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e15a408f71a6c8c87b364f1f15a6cd9c1baca12bbc47a326ac8ab99ec7ad3c64"}, + {file = "hiredis-2.3.2.tar.gz", hash = "sha256:733e2456b68f3f126ddaf2cd500a33b25146c3676b97ea843665717bda0c5d43"}, +] + [[package]] name = "hpack" version = "4.0.0" @@ -839,124 +957,165 @@ files = [ [[package]] name = "lxml" -version = "5.2.0" +version = "5.2.1" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" files = [ - {file = "lxml-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c54f8d6160080831a76780d850302fdeb0e8d0806f661777b0714dfb55d9a08a"}, - {file = "lxml-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e95ae029396382a0d2e8174e4077f96befcd4a2184678db363ddc074eb4d3b2"}, - {file = "lxml-5.2.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5810fa80e64a0c689262a71af999c5735f48c0da0affcbc9041d1ef5ef3920be"}, - {file = "lxml-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae69524fd6a68b288574013f8fadac23cacf089c75cd3fc5b216277a445eb736"}, - {file = "lxml-5.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fadda215e32fe375d65e560b7f7e2a37c7f9c4ecee5315bb1225ca6ac9bf5838"}, - {file = "lxml-5.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f1f164e4cc6bc646b1fc86664c3543bf4a941d45235797279b120dc740ee7af5"}, - {file = "lxml-5.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3603a8a41097daf7672cae22cc4a860ab9ea5597f1c5371cb21beca3398b8d6a"}, - {file = "lxml-5.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3b4bb89a785f4fd60e05f3c3a526c07d0d68e3536f17f169ca13bf5b5dd75a5"}, - {file = "lxml-5.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1effc10bf782f0696e76ecfeba0720ea02c0c31d5bffb7b29ba10debd57d1c3d"}, - {file = "lxml-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b03531f6cd6ce4b511dcece060ca20aa5412f8db449274b44f4003f282e6272f"}, - {file = "lxml-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fac15090bb966719df06f0c4f8139783746d1e60e71016d8a65db2031ca41b8"}, - {file = "lxml-5.2.0-cp310-cp310-win32.whl", hash = "sha256:92bb37c96215c4b2eb26f3c791c0bf02c64dd251effa532b43ca5049000c4478"}, - {file = "lxml-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:b0181c22fdb89cc19e70240a850e5480817c3e815b1eceb171b3d7a3aa3e596a"}, - {file = "lxml-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ada8ce9e6e1d126ef60d215baaa0c81381ba5841c25f1d00a71cdafdc038bd27"}, - {file = "lxml-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cefb133c859f06dab2ae63885d9f405000c4031ec516e0ed4f9d779f690d8e3"}, - {file = "lxml-5.2.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ede2a7a86a977b0c741654efaeca0af7860a9b1ae39f9268f0936246a977ee0"}, - {file = "lxml-5.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46df6f0b1a0cda39d12c5c4615a7d92f40342deb8001c7b434d7c8c78352e58"}, - {file = "lxml-5.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2259243ee734cc736e237719037efb86603c891fd363cc7973a2d0ac8a0e3f"}, - {file = "lxml-5.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c53164f29ed3c3868787144e8ea8a399ffd7d8215f59500a20173593c19e96eb"}, - {file = "lxml-5.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:371aab9a397dcc76625ad3b02fa9b21be63406d69237b773156e7d1fc2ce0cae"}, - {file = "lxml-5.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e08784288a179b59115b5e57abf6d387528b39abb61105fe17510a199a277a40"}, - {file = "lxml-5.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c232726f7b6df5143415a06323faaa998ef8abbe1c0ed00d718755231d76f08"}, - {file = "lxml-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4366e58c0508da4dee4c7c70cee657e38553d73abdffa53abbd7d743711ee11"}, - {file = "lxml-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c84dce8fb2e900d4fb094e76fdad34a5fd06de53e41bddc1502c146eb11abd74"}, - {file = "lxml-5.2.0-cp311-cp311-win32.whl", hash = "sha256:0947d1114e337dc2aae2fa14bbc9ed5d9ca1a0acd6d2f948df9926aef65305e9"}, - {file = "lxml-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1eace37a9f4a1bef0bb5c849434933fd6213008ec583c8e31ee5b8e99c7c8500"}, - {file = "lxml-5.2.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f2cb157e279d28c66b1c27e0948687dc31dc47d1ab10ce0cd292a8334b7de3d5"}, - {file = "lxml-5.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:53c0e56f41ef68c1ce4e96f27ecdc2df389730391a2fd45439eb3facb02d36c8"}, - {file = "lxml-5.2.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:703d60e59ab45c17485c2c14b11880e4f7f0eab07134afa9007573fa5a779a5a"}, - {file = "lxml-5.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaf5e308a5e50bc0548c4fdca0117a31ec9596f8cfc96592db170bcecc71a957"}, - {file = "lxml-5.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af64df85fecd3cf3b2e792f0b5b4d92740905adfa8ce3b24977a55415f1a0c40"}, - {file = "lxml-5.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:df7dfbdef11702fd22c2eaf042d7098d17edbc62d73f2199386ad06cbe466f6d"}, - {file = "lxml-5.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7250030a7835bfd5ba6ca7d1ad483ec90f9cbc29978c5e75c1cc3e031d3c4160"}, - {file = "lxml-5.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:be5faa2d5c8c8294d770cfd09d119fb27b5589acc59635b0cf90f145dbe81dca"}, - {file = "lxml-5.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:347ec08250d5950f5b016caa3e2e13fb2cb9714fe6041d52e3716fb33c208663"}, - {file = "lxml-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc7b630c4fb428b8a40ddd0bfc4bc19de11bb3c9b031154f77360e48fe8b4451"}, - {file = "lxml-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ae550cbd7f229cdf2841d9b01406bcca379a5fb327b9efb53ba620a10452e835"}, - {file = "lxml-5.2.0-cp312-cp312-win32.whl", hash = "sha256:7c61ce3cdd6e6c9f4003ac118be7eb3036d0ce2afdf23929e533e54482780f74"}, - {file = "lxml-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f90c36ca95a44d2636bbf55a51ca30583b59b71b6547b88d954e029598043551"}, - {file = "lxml-5.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1cce2eaad7e38b985b0f91f18468dda0d6b91862d32bec945b0e46e2ffe7222e"}, - {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60a3983d32f722a8422c01e4dc4badc7a307ca55c59e2485d0e14244a52c482f"}, - {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60847dfbdfddf08a56c4eefe48234e8c1ab756c7eda4a2a7c1042666a5516564"}, - {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bbe335f0d1a86391671d975a1b5e9b08bb72fba6b567c43bdc2e55ca6e6c086"}, - {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_28_aarch64.whl", hash = "sha256:3ac7c8a60b8ad51fe7bca99a634dd625d66492c502fd548dc6dc769ce7d94b6a"}, - {file = "lxml-5.2.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:73e69762cf740ac3ae81137ef9d6f15f93095f50854e233d50b29e7b8a91dbc6"}, - {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:281ee1ffeb0ab06204dfcd22a90e9003f0bb2dab04101ad983d0b1773bc10588"}, - {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ba3a86b0d5a5c93104cb899dff291e3ae13729c389725a876d00ef9696de5425"}, - {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:356f8873b1e27b81793e30144229adf70f6d3e36e5cb7b6d289da690f4398953"}, - {file = "lxml-5.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2a34e74ffe92c413f197ff4967fb1611d938ee0691b762d062ef0f73814f3aa4"}, - {file = "lxml-5.2.0-cp36-cp36m-win32.whl", hash = "sha256:6f0d2b97a5a06c00c963d4542793f3e486b1ed3a957f8c19f6006ed39d104bb0"}, - {file = "lxml-5.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:35e39c6fd089ad6674eb52d93aa874d6027b3ae44d2381cca6e9e4c2e102c9c8"}, - {file = "lxml-5.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5f6e4e5a62114ae76690c4a04c5108d067442d0a41fd092e8abd25af1288c450"}, - {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93eede9bcc842f891b2267c7f0984d811940d1bc18472898a1187fe560907a99"}, - {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad364026c2cebacd7e01d1138bd53639822fefa8f7da90fc38cd0e6319a2699"}, - {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f06e4460e76468d99cc36d5b9bc6fc5f43e6662af44960e13e3f4e040aacb35"}, - {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ca3236f31d565555139d5b00b790ed2a98ac6f0c4470c4032f8b5e5a5dba3c1a"}, - {file = "lxml-5.2.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:a9b67b850ab1d304cb706cf71814b0e0c3875287083d7ec55ee69504a9c48180"}, - {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5261c858c390ae9a19aba96796948b6a2d56649cbd572968970dc8da2b2b2a42"}, - {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e8359fb610c8c444ac473cfd82dae465f405ff807cabb98a9b9712bbd0028751"}, - {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:f9e27841cddfaebc4e3ffbe5dbdff42891051acf5befc9f5323944b2c61cef16"}, - {file = "lxml-5.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:641a8da145aca67671205f3e89bfec9815138cf2fe06653c909eab42e486d373"}, - {file = "lxml-5.2.0-cp37-cp37m-win32.whl", hash = "sha256:931a3a13e0f574abce8f3152b207938a54304ccf7a6fd7dff1fdb2f6691d08af"}, - {file = "lxml-5.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:246c93e2503c710cf02c7e9869dc0258223cbefe5e8f9ecded0ac0aa07fd2bf8"}, - {file = "lxml-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:11acfcdf5a38cf89c48662123a5d02ae0a7d99142c7ee14ad90de5c96a9b6f06"}, - {file = "lxml-5.2.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:200f70b5d95fc79eb9ed7f8c4888eef4e274b9bf380b829d3d52e9ed962e9231"}, - {file = "lxml-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba4d02aed47c25be6775a40d55c5774327fdedba79871b7c2485e80e45750cb2"}, - {file = "lxml-5.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e283b24c14361fe9e04026a1d06c924450415491b83089951d469509900d9f32"}, - {file = "lxml-5.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:03e3962d6ad13a862dacd5b3a3ea60b4d092a550f36465234b8639311fd60989"}, - {file = "lxml-5.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6e45fd5213e5587a610b7e7c8c5319a77591ab21ead42df46bb342e21bc1418d"}, - {file = "lxml-5.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:27877732946843f4b6bfc56eb40d865653eef34ad2edeed16b015d5c29c248df"}, - {file = "lxml-5.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4d16b44ad0dd8c948129639e34c8d301ad87ebc852568ace6fe9a5ad9ce67ee1"}, - {file = "lxml-5.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b8f842df9ba26135c5414e93214e04fe0af259bb4f96a32f756f89467f7f3b45"}, - {file = "lxml-5.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c74e77df9e36c8c91157853e6cd400f6f9ca7a803ba89981bfe3f3fc7e5651ef"}, - {file = "lxml-5.2.0-cp38-cp38-win32.whl", hash = "sha256:1459a998c10a99711ac532abe5cc24ba354e4396dafef741c7797f8830712d56"}, - {file = "lxml-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:a00f5931b7cccea775123c3c0a2513aee58afdad8728550cc970bff32280bdd2"}, - {file = "lxml-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ddda5ba8831f258ac7e6364be03cb27aa62f50c67fd94bc1c3b6247959cc0369"}, - {file = "lxml-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56835b9e9a7767202fae06310c6b67478963e535fe185bed3bf9af5b18d2b67e"}, - {file = "lxml-5.2.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25fef8794f0dc89f01bdd02df6a7fec4bcb2fbbe661d571e898167a83480185e"}, - {file = "lxml-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d44af078485c4da9a7ec460162392d49d996caf89516fa0b75ad0838047122"}, - {file = "lxml-5.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f354d62345acdf22aa3e171bd9723790324a66fafe61bfe3873b86724cf6daaa"}, - {file = "lxml-5.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6a7e0935f05e1cf1a3aa1d49a87505773b04f128660eac2a24a5594ea6b1baa7"}, - {file = "lxml-5.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:75a4117b43694c72a0d89f6c18a28dc57407bde4650927d4ef5fd384bdf6dcc7"}, - {file = "lxml-5.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:57402d6cdd8a897ce21cf8d1ff36683583c17a16322a321184766c89a1980600"}, - {file = "lxml-5.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:56591e477bea531e5e1854f5dfb59309d5708669bc921562a35fd9ca5182bdcd"}, - {file = "lxml-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7efbce96719aa275d49ad5357886845561328bf07e1d5ab998f4e3066c5ccf15"}, - {file = "lxml-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a3c39def0965e8fb5c8d50973e0c7b4ce429a2fa730f3f9068a7f4f9ce78410b"}, - {file = "lxml-5.2.0-cp39-cp39-win32.whl", hash = "sha256:5188f22c00381cb44283ecb28c8d85c2db4a3035774dd851876c8647cb809c27"}, - {file = "lxml-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:ed1fe80e1fcdd1205a443bddb1ad3c3135bb1cd3f36cc996a1f4aed35960fbe8"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d2b339fb790fc923ae2e9345c8633e3d0064d37ea7920c027f20c8ae6f65a91f"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06036d60fccb21e22dd167f6d0e422b9cbdf3588a7e999a33799f9cbf01e41a5"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1611fb9de0a269c05575c024e6d8cdf2186e3fa52b364e3b03dcad82514d57"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:05fc3720250d221792b6e0d150afc92d20cb10c9cdaa8c8f93c2a00fbdd16015"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:11e41ffd3cd27b0ca1c76073b27bd860f96431d9b70f383990f1827ca19f2f52"}, - {file = "lxml-5.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0382e6a3eefa3f6699b14fa77c2eb32af2ada261b75120eaf4fc028a20394975"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:be5c8e776ecbcf8c1bce71a7d90e3a3680c9ceae516cac0be08b47e9fac0ca43"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da12b4efc93d53068888cb3b58e355b31839f2428b8f13654bd25d68b201c240"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f8033da364bacc74aca5e319509a20bb711c8a133680ca5f35020f9eaf025"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:50a26f68d090594477df8572babac64575cd5c07373f7a8319c527c8e56c0f99"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:57cbadf028727705086047994d2e50124650e63ce5a035b0aa79ab50f001989f"}, - {file = "lxml-5.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:8aa11638902ac23f944f16ce45c9f04c9d5d57bb2da66822abb721f4efe5fdbb"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7150e630b879390e02121e71ceb1807f682b88342e2ea2082e2c8716cf8bd93"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4add722393c99da4d51c8d9f3e1ddf435b30677f2d9ba9aeaa656f23c1b7b580"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd0f25a431cd16f70ec1c47c10b413e7ddfe1ccaaddd1a7abd181e507c012374"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:883e382695f346c2ea3ad96bdbdf4ca531788fbeedb4352be3a8fcd169fc387d"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:80cc2b55bb6e35d3cb40936b658837eb131e9f16357241cd9ba106ae1e9c5ecb"}, - {file = "lxml-5.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:59ec2948385336e9901008fdf765780fe30f03e7fdba8090aafdbe5d1b7ea0cd"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ddbea6e58cce1a640d9d65947f1e259423fc201c9cf9761782f355f53b7f3097"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52d6cdea438eb7282c41c5ac00bd6d47d14bebb6e8a8d2a1c168ed9e0cacfbab"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c556bbf88a8b667c849d326dd4dd9c6290ede5a33383ffc12b0ed17777f909d"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:947fa8bf15d1c62c6db36c6ede9389cac54f59af27010251747f05bddc227745"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e6cb8f7a332eaa2d876b649a748a445a38522e12f2168e5e838d1505a91cdbb7"}, - {file = "lxml-5.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:16e65223f34fd3d65259b174f0f75a4bb3d9893698e5e7d01e54cd8c5eb98d85"}, - {file = "lxml-5.2.0.tar.gz", hash = "sha256:21dc490cdb33047bc7f7ad76384f3366fa8f5146b86cc04c4af45de901393b90"}, + {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1f7785f4f789fdb522729ae465adcaa099e2a3441519df750ebdccc481d961a1"}, + {file = "lxml-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cc6ee342fb7fa2471bd9b6d6fdfc78925a697bf5c2bcd0a302e98b0d35bfad3"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:794f04eec78f1d0e35d9e0c36cbbb22e42d370dda1609fb03bcd7aeb458c6377"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817d420c60a5183953c783b0547d9eb43b7b344a2c46f69513d5952a78cddf3"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2213afee476546a7f37c7a9b4ad4d74b1e112a6fafffc9185d6d21f043128c81"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b070bbe8d3f0f6147689bed981d19bbb33070225373338df755a46893528104a"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e02c5175f63effbd7c5e590399c118d5db6183bbfe8e0d118bdb5c2d1b48d937"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d7520db34088c96cc0e0a3ad51a4fd5b401f279ee112aa2b7f8f976d8582606d"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:bcbf4af004f98793a95355980764b3d80d47117678118a44a80b721c9913436a"}, + {file = "lxml-5.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2b44bec7adf3e9305ce6cbfa47a4395667e744097faed97abb4728748ba7d47"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1c5bb205e9212d0ebddf946bc07e73fa245c864a5f90f341d11ce7b0b854475d"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2c9d147f754b1b0e723e6afb7ba1566ecb162fe4ea657f53d2139bbf894d050a"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:3545039fa4779be2df51d6395e91a810f57122290864918b172d5dc7ca5bb433"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a91481dbcddf1736c98a80b122afa0f7296eeb80b72344d7f45dc9f781551f56"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2ddfe41ddc81f29a4c44c8ce239eda5ade4e7fc305fb7311759dd6229a080052"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7baf9ffc238e4bf401299f50e971a45bfcc10a785522541a6e3179c83eabf0a"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31e9a882013c2f6bd2f2c974241bf4ba68c85eba943648ce88936d23209a2e01"}, + {file = "lxml-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a15438253b34e6362b2dc41475e7f80de76320f335e70c5528b7148cac253a1"}, + {file = "lxml-5.2.1-cp310-cp310-win32.whl", hash = "sha256:6992030d43b916407c9aa52e9673612ff39a575523c5f4cf72cdef75365709a5"}, + {file = "lxml-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:da052e7962ea2d5e5ef5bc0355d55007407087392cf465b7ad84ce5f3e25fe0f"}, + {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:70ac664a48aa64e5e635ae5566f5227f2ab7f66a3990d67566d9907edcbbf867"}, + {file = "lxml-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1ae67b4e737cddc96c99461d2f75d218bdf7a0c3d3ad5604d1f5e7464a2f9ffe"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f18a5a84e16886898e51ab4b1d43acb3083c39b14c8caeb3589aabff0ee0b270"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6f2c8372b98208ce609c9e1d707f6918cc118fea4e2c754c9f0812c04ca116d"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:394ed3924d7a01b5bd9a0d9d946136e1c2f7b3dc337196d99e61740ed4bc6fe1"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d077bc40a1fe984e1a9931e801e42959a1e6598edc8a3223b061d30fbd26bbc"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:764b521b75701f60683500d8621841bec41a65eb739b8466000c6fdbc256c240"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3a6b45da02336895da82b9d472cd274b22dc27a5cea1d4b793874eead23dd14f"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:5ea7b6766ac2dfe4bcac8b8595107665a18ef01f8c8343f00710b85096d1b53a"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e196a4ff48310ba62e53a8e0f97ca2bca83cdd2fe2934d8b5cb0df0a841b193a"}, + {file = "lxml-5.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:200e63525948e325d6a13a76ba2911f927ad399ef64f57898cf7c74e69b71095"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dae0ed02f6b075426accbf6b2863c3d0a7eacc1b41fb40f2251d931e50188dad"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:ab31a88a651039a07a3ae327d68ebdd8bc589b16938c09ef3f32a4b809dc96ef"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:df2e6f546c4df14bc81f9498bbc007fbb87669f1bb707c6138878c46b06f6510"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5dd1537e7cc06efd81371f5d1a992bd5ab156b2b4f88834ca852de4a8ea523fa"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b9ec9c9978b708d488bec36b9e4c94d88fd12ccac3e62134a9d17ddba910ea9"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8e77c69d5892cb5ba71703c4057091e31ccf534bd7f129307a4d084d90d014b8"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8d5c70e04aac1eda5c829a26d1f75c6e5286c74743133d9f742cda8e53b9c2f"}, + {file = "lxml-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c94e75445b00319c1fad60f3c98b09cd63fe1134a8a953dcd48989ef42318534"}, + {file = "lxml-5.2.1-cp311-cp311-win32.whl", hash = "sha256:4951e4f7a5680a2db62f7f4ab2f84617674d36d2d76a729b9a8be4b59b3659be"}, + {file = "lxml-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c670c0406bdc845b474b680b9a5456c561c65cf366f8db5a60154088c92d102"}, + {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:abc25c3cab9ec7fcd299b9bcb3b8d4a1231877e425c650fa1c7576c5107ab851"}, + {file = "lxml-5.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6935bbf153f9a965f1e07c2649c0849d29832487c52bb4a5c5066031d8b44fd5"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d793bebb202a6000390a5390078e945bbb49855c29c7e4d56a85901326c3b5d9"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd5562927cdef7c4f5550374acbc117fd4ecc05b5007bdfa57cc5355864e0a4"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e7259016bc4345a31af861fdce942b77c99049d6c2107ca07dc2bba2435c1d9"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:530e7c04f72002d2f334d5257c8a51bf409db0316feee7c87e4385043be136af"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59689a75ba8d7ffca577aefd017d08d659d86ad4585ccc73e43edbfc7476781a"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f9737bf36262046213a28e789cc82d82c6ef19c85a0cf05e75c670a33342ac2c"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:3a74c4f27167cb95c1d4af1c0b59e88b7f3e0182138db2501c353555f7ec57f4"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:68a2610dbe138fa8c5826b3f6d98a7cfc29707b850ddcc3e21910a6fe51f6ca0"}, + {file = "lxml-5.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f0a1bc63a465b6d72569a9bba9f2ef0334c4e03958e043da1920299100bc7c08"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c2d35a1d047efd68027817b32ab1586c1169e60ca02c65d428ae815b593e65d4"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:79bd05260359170f78b181b59ce871673ed01ba048deef4bf49a36ab3e72e80b"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:865bad62df277c04beed9478fe665b9ef63eb28fe026d5dedcb89b537d2e2ea6"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71e97313406ccf55d32cc98a533ee05c61e15d11b99215b237346171c179c0b0"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:057cdc6b86ab732cf361f8b4d8af87cf195a1f6dc5b0ff3de2dced242c2015e0"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3bbbc998d42f8e561f347e798b85513ba4da324c2b3f9b7969e9c45b10f6169"}, + {file = "lxml-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:491755202eb21a5e350dae00c6d9a17247769c64dcf62d8c788b5c135e179dc4"}, + {file = "lxml-5.2.1-cp312-cp312-win32.whl", hash = "sha256:8de8f9d6caa7f25b204fc861718815d41cbcf27ee8f028c89c882a0cf4ae4134"}, + {file = "lxml-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f2a9efc53d5b714b8df2b4b3e992accf8ce5bbdfe544d74d5c6766c9e1146a3a"}, + {file = "lxml-5.2.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:70a9768e1b9d79edca17890175ba915654ee1725975d69ab64813dd785a2bd5c"}, + {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c38d7b9a690b090de999835f0443d8aa93ce5f2064035dfc48f27f02b4afc3d0"}, + {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5670fb70a828663cc37552a2a85bf2ac38475572b0e9b91283dc09efb52c41d1"}, + {file = "lxml-5.2.1-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:958244ad566c3ffc385f47dddde4145088a0ab893504b54b52c041987a8c1863"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2a66bf12fbd4666dd023b6f51223aed3d9f3b40fef06ce404cb75bafd3d89536"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:9123716666e25b7b71c4e1789ec829ed18663152008b58544d95b008ed9e21e9"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:0c3f67e2aeda739d1cc0b1102c9a9129f7dc83901226cc24dd72ba275ced4218"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5d5792e9b3fb8d16a19f46aa8208987cfeafe082363ee2745ea8b643d9cc5b45"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:88e22fc0a6684337d25c994381ed8a1580a6f5ebebd5ad41f89f663ff4ec2885"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:21c2e6b09565ba5b45ae161b438e033a86ad1736b8c838c766146eff8ceffff9"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:afbbdb120d1e78d2ba8064a68058001b871154cc57787031b645c9142b937a62"}, + {file = "lxml-5.2.1-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:627402ad8dea044dde2eccde4370560a2b750ef894c9578e1d4f8ffd54000461"}, + {file = "lxml-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:e89580a581bf478d8dcb97d9cd011d567768e8bc4095f8557b21c4d4c5fea7d0"}, + {file = "lxml-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:59565f10607c244bc4c05c0c5fa0c190c990996e0c719d05deec7030c2aa8289"}, + {file = "lxml-5.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:857500f88b17a6479202ff5fe5f580fc3404922cd02ab3716197adf1ef628029"}, + {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56c22432809085b3f3ae04e6e7bdd36883d7258fcd90e53ba7b2e463efc7a6af"}, + {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a55ee573116ba208932e2d1a037cc4b10d2c1cb264ced2184d00b18ce585b2c0"}, + {file = "lxml-5.2.1-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:6cf58416653c5901e12624e4013708b6e11142956e7f35e7a83f1ab02f3fe456"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:64c2baa7774bc22dd4474248ba16fe1a7f611c13ac6123408694d4cc93d66dbd"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:74b28c6334cca4dd704e8004cba1955af0b778cf449142e581e404bd211fb619"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:7221d49259aa1e5a8f00d3d28b1e0b76031655ca74bb287123ef56c3db92f213"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:04ab5415bf6c86e0518d57240a96c4d1fcfc3cb370bb2ac2a732b67f579e5a04"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:6ab833e4735a7e5533711a6ea2df26459b96f9eec36d23f74cafe03631647c41"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f443cdef978430887ed55112b491f670bba6462cea7a7742ff8f14b7abb98d75"}, + {file = "lxml-5.2.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9e2addd2d1866fe112bc6f80117bcc6bc25191c5ed1bfbcf9f1386a884252ae8"}, + {file = "lxml-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:f51969bac61441fd31f028d7b3b45962f3ecebf691a510495e5d2cd8c8092dbd"}, + {file = "lxml-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b58fbfa1bf7367dde8a557994e3b1637294be6cf2169810375caf8571a085c"}, + {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3e183c6e3298a2ed5af9d7a356ea823bccaab4ec2349dc9ed83999fd289d14d5"}, + {file = "lxml-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:804f74efe22b6a227306dd890eecc4f8c59ff25ca35f1f14e7482bbce96ef10b"}, + {file = "lxml-5.2.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:08802f0c56ed150cc6885ae0788a321b73505d2263ee56dad84d200cab11c07a"}, + {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f8c09ed18ecb4ebf23e02b8e7a22a05d6411911e6fabef3a36e4f371f4f2585"}, + {file = "lxml-5.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3d30321949861404323c50aebeb1943461a67cd51d4200ab02babc58bd06a86"}, + {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:b560e3aa4b1d49e0e6c847d72665384db35b2f5d45f8e6a5c0072e0283430533"}, + {file = "lxml-5.2.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:058a1308914f20784c9f4674036527e7c04f7be6fb60f5d61353545aa7fcb739"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:adfb84ca6b87e06bc6b146dc7da7623395db1e31621c4785ad0658c5028b37d7"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a2dfe7e2473f9b59496247aad6e23b405ddf2e12ef0765677b0081c02d6c2c0b"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bf2e2458345d9bffb0d9ec16557d8858c9c88d2d11fed53998512504cd9df49b"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:58278b29cb89f3e43ff3e0c756abbd1518f3ee6adad9e35b51fb101c1c1daaec"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:64641a6068a16201366476731301441ce93457eb8452056f570133a6ceb15fca"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:78bfa756eab503673991bdcf464917ef7845a964903d3302c5f68417ecdc948c"}, + {file = "lxml-5.2.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:11a04306fcba10cd9637e669fd73aa274c1c09ca64af79c041aa820ea992b637"}, + {file = "lxml-5.2.1-cp38-cp38-win32.whl", hash = "sha256:66bc5eb8a323ed9894f8fa0ee6cb3e3fb2403d99aee635078fd19a8bc7a5a5da"}, + {file = "lxml-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:9676bfc686fa6a3fa10cd4ae6b76cae8be26eb5ec6811d2a325636c460da1806"}, + {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cf22b41fdae514ee2f1691b6c3cdeae666d8b7fa9434de445f12bbeee0cf48dd"}, + {file = "lxml-5.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec42088248c596dbd61d4ae8a5b004f97a4d91a9fd286f632e42e60b706718d7"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd53553ddad4a9c2f1f022756ae64abe16da1feb497edf4d9f87f99ec7cf86bd"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feaa45c0eae424d3e90d78823f3828e7dc42a42f21ed420db98da2c4ecf0a2cb"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddc678fb4c7e30cf830a2b5a8d869538bc55b28d6c68544d09c7d0d8f17694dc"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e074d4931dbcba7480d4dcab23d5c56bd9607f92825ab80ee2bd916edea53"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc4691d60512798304acb9207987e7b2b7c44627ea88b9d77489bbe3e6cc3bd4"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:beb72935a941965c52990f3a32d7f07ce869fe21c6af8b34bf6a277b33a345d3"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6588c459c5627fefa30139be4d2e28a2c2a1d0d1c265aad2ba1935a7863a4913"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:588008b8497667f1ddca7c99f2f85ce8511f8f7871b4a06ceede68ab62dff64b"}, + {file = "lxml-5.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6787b643356111dfd4032b5bffe26d2f8331556ecb79e15dacb9275da02866e"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c17b64b0a6ef4e5affae6a3724010a7a66bda48a62cfe0674dabd46642e8b54"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:27aa20d45c2e0b8cd05da6d4759649170e8dfc4f4e5ef33a34d06f2d79075d57"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d4f2cc7060dc3646632d7f15fe68e2fa98f58e35dd5666cd525f3b35d3fed7f8"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff46d772d5f6f73564979cd77a4fffe55c916a05f3cb70e7c9c0590059fb29ef"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96323338e6c14e958d775700ec8a88346014a85e5de73ac7967db0367582049b"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:52421b41ac99e9d91934e4d0d0fe7da9f02bfa7536bb4431b4c05c906c8c6919"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:7a7efd5b6d3e30d81ec68ab8a88252d7c7c6f13aaa875009fe3097eb4e30b84c"}, + {file = "lxml-5.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ed777c1e8c99b63037b91f9d73a6aad20fd035d77ac84afcc205225f8f41188"}, + {file = "lxml-5.2.1-cp39-cp39-win32.whl", hash = "sha256:644df54d729ef810dcd0f7732e50e5ad1bd0a135278ed8d6bcb06f33b6b6f708"}, + {file = "lxml-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:9ca66b8e90daca431b7ca1408cae085d025326570e57749695d6a01454790e95"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b0ff53900566bc6325ecde9181d89afadc59c5ffa39bddf084aaedfe3b06a11"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd6037392f2d57793ab98d9e26798f44b8b4da2f2464388588f48ac52c489ea1"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9c07e7a45bb64e21df4b6aa623cb8ba214dfb47d2027d90eac197329bb5e94"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3249cc2989d9090eeac5467e50e9ec2d40704fea9ab72f36b034ea34ee65ca98"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f42038016852ae51b4088b2862126535cc4fc85802bfe30dea3500fdfaf1864e"}, + {file = "lxml-5.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:533658f8fbf056b70e434dff7e7aa611bcacb33e01f75de7f821810e48d1bb66"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:622020d4521e22fb371e15f580d153134bfb68d6a429d1342a25f051ec72df1c"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efa7b51824aa0ee957ccd5a741c73e6851de55f40d807f08069eb4c5a26b2baa"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c6ad0fbf105f6bcc9300c00010a2ffa44ea6f555df1a2ad95c88f5656104817"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e233db59c8f76630c512ab4a4daf5a5986da5c3d5b44b8e9fc742f2a24dbd460"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6a014510830df1475176466b6087fc0c08b47a36714823e58d8b8d7709132a96"}, + {file = "lxml-5.2.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d38c8f50ecf57f0463399569aa388b232cf1a2ffb8f0a9a5412d0db57e054860"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5aea8212fb823e006b995c4dda533edcf98a893d941f173f6c9506126188860d"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff097ae562e637409b429a7ac958a20aab237a0378c42dabaa1e3abf2f896e5f"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5d65c39f16717a47c36c756af0fb36144069c4718824b7533f803ecdf91138"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e32be23d538753a8adb6c85bd539f5fd3b15cb987404327c569dfc5fd8366e85"}, + {file = "lxml-5.2.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cc518cea79fd1e2f6c90baafa28906d4309d24f3a63e801d855e7424c5b34144"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a0af35bd8ebf84888373630f73f24e86bf016642fb8576fba49d3d6b560b7cbc"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8aca2e3a72f37bfc7b14ba96d4056244001ddcc18382bd0daa087fd2e68a354"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ca1e8188b26a819387b29c3895c47a5e618708fe6f787f3b1a471de2c4a94d9"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c8ba129e6d3b0136a0f50345b2cb3db53f6bda5dd8c7f5d83fbccba97fb5dcb5"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e998e304036198b4f6914e6a1e2b6f925208a20e2042563d9734881150c6c246"}, + {file = "lxml-5.2.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d3be9b2076112e51b323bdf6d5a7f8a798de55fb8d95fcb64bd179460cdc0704"}, + {file = "lxml-5.2.1.tar.gz", hash = "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306"}, ] [package.extras] @@ -1688,6 +1847,24 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "redis" +version = "5.0.3" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.3-py3-none-any.whl", hash = "sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d"}, + {file = "redis-5.0.3.tar.gz", hash = "sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580"}, +] + +[package.dependencies] +hiredis = {version = ">=1.0.0", optional = true, markers = "extra == \"hiredis\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "scikit-learn" version = "1.4.1.post1" @@ -1732,55 +1909,55 @@ tests = ["black (>=23.3.0)", "matplotlib (>=3.3.4)", "mypy (>=1.3)", "numpydoc ( [[package]] name = "scipy" -version = "1.12.0" +version = "1.13.0" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.9" files = [ - {file = "scipy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:78e4402e140879387187f7f25d91cc592b3501a2e51dfb320f48dfb73565f10b"}, - {file = "scipy-1.12.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:f5f00ebaf8de24d14b8449981a2842d404152774c1a1d880c901bf454cb8e2a1"}, - {file = "scipy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e53958531a7c695ff66c2e7bb7b79560ffdc562e2051644c5576c39ff8efb563"}, - {file = "scipy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e32847e08da8d895ce09d108a494d9eb78974cf6de23063f93306a3e419960c"}, - {file = "scipy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4c1020cad92772bf44b8e4cdabc1df5d87376cb219742549ef69fc9fd86282dd"}, - {file = "scipy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:75ea2a144096b5e39402e2ff53a36fecfd3b960d786b7efd3c180e29c39e53f2"}, - {file = "scipy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:408c68423f9de16cb9e602528be4ce0d6312b05001f3de61fe9ec8b1263cad08"}, - {file = "scipy-1.12.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5adfad5dbf0163397beb4aca679187d24aec085343755fcdbdeb32b3679f254c"}, - {file = "scipy-1.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3003652496f6e7c387b1cf63f4bb720951cfa18907e998ea551e6de51a04467"}, - {file = "scipy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b8066bce124ee5531d12a74b617d9ac0ea59245246410e19bca549656d9a40a"}, - {file = "scipy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8bee4993817e204d761dba10dbab0774ba5a8612e57e81319ea04d84945375ba"}, - {file = "scipy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a24024d45ce9a675c1fb8494e8e5244efea1c7a09c60beb1eeb80373d0fecc70"}, - {file = "scipy-1.12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e7e76cc48638228212c747ada851ef355c2bb5e7f939e10952bc504c11f4e372"}, - {file = "scipy-1.12.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f7ce148dffcd64ade37b2df9315541f9adad6efcaa86866ee7dd5db0c8f041c3"}, - {file = "scipy-1.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c39f92041f490422924dfdb782527a4abddf4707616e07b021de33467f917bc"}, - {file = "scipy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ebda398f86e56178c2fa94cad15bf457a218a54a35c2a7b4490b9f9cb2676c"}, - {file = "scipy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:95e5c750d55cf518c398a8240571b0e0782c2d5a703250872f36eaf737751338"}, - {file = "scipy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e646d8571804a304e1da01040d21577685ce8e2db08ac58e543eaca063453e1c"}, - {file = "scipy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:913d6e7956c3a671de3b05ccb66b11bc293f56bfdef040583a7221d9e22a2e35"}, - {file = "scipy-1.12.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba1b0c7256ad75401c73e4b3cf09d1f176e9bd4248f0d3112170fb2ec4db067"}, - {file = "scipy-1.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:730badef9b827b368f351eacae2e82da414e13cf8bd5051b4bdfd720271a5371"}, - {file = "scipy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6546dc2c11a9df6926afcbdd8a3edec28566e4e785b915e849348c6dd9f3f490"}, - {file = "scipy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:196ebad3a4882081f62a5bf4aeb7326aa34b110e533aab23e4374fcccb0890dc"}, - {file = "scipy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:b360f1b6b2f742781299514e99ff560d1fe9bd1bff2712894b52abe528d1fd1e"}, - {file = "scipy-1.12.0.tar.gz", hash = "sha256:4bf5abab8a36d20193c698b0f1fc282c1d083c94723902c447e5d2f1780936a3"}, + {file = "scipy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba419578ab343a4e0a77c0ef82f088238a93eef141b2b8017e46149776dfad4d"}, + {file = "scipy-1.13.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:22789b56a999265431c417d462e5b7f2b487e831ca7bef5edeb56efe4c93f86e"}, + {file = "scipy-1.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f1432ba070e90d42d7fd836462c50bf98bd08bed0aa616c359eed8a04e3922"}, + {file = "scipy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8434f6f3fa49f631fae84afee424e2483289dfc30a47755b4b4e6b07b2633a4"}, + {file = "scipy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:dcbb9ea49b0167de4167c40eeee6e167caeef11effb0670b554d10b1e693a8b9"}, + {file = "scipy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:1d2f7bb14c178f8b13ebae93f67e42b0a6b0fc50eba1cd8021c9b6e08e8fb1cd"}, + {file = "scipy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fbcf8abaf5aa2dc8d6400566c1a727aed338b5fe880cde64907596a89d576fa"}, + {file = "scipy-1.13.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5e4a756355522eb60fcd61f8372ac2549073c8788f6114449b37e9e8104f15a5"}, + {file = "scipy-1.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5acd8e1dbd8dbe38d0004b1497019b2dbbc3d70691e65d69615f8a7292865d7"}, + {file = "scipy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ff7dad5d24a8045d836671e082a490848e8639cabb3dbdacb29f943a678683d"}, + {file = "scipy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4dca18c3ffee287ddd3bc8f1dabaf45f5305c5afc9f8ab9cbfab855e70b2df5c"}, + {file = "scipy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a2f471de4d01200718b2b8927f7d76b5d9bde18047ea0fa8bd15c5ba3f26a1d6"}, + {file = "scipy-1.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0de696f589681c2802f9090fff730c218f7c51ff49bf252b6a97ec4a5d19e8b"}, + {file = "scipy-1.13.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b2a3ff461ec4756b7e8e42e1c681077349a038f0686132d623fa404c0bee2551"}, + {file = "scipy-1.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf9fe63e7a4bf01d3645b13ff2aa6dea023d38993f42aaac81a18b1bda7a82a"}, + {file = "scipy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e7626dfd91cdea5714f343ce1176b6c4745155d234f1033584154f60ef1ff42"}, + {file = "scipy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:109d391d720fcebf2fbe008621952b08e52907cf4c8c7efc7376822151820820"}, + {file = "scipy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8930ae3ea371d6b91c203b1032b9600d69c568e537b7988a3073dfe4d4774f21"}, + {file = "scipy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5407708195cb38d70fd2d6bb04b1b9dd5c92297d86e9f9daae1576bd9e06f602"}, + {file = "scipy-1.13.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ac38c4c92951ac0f729c4c48c9e13eb3675d9986cc0c83943784d7390d540c78"}, + {file = "scipy-1.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c74543c4fbeb67af6ce457f6a6a28e5d3739a87f62412e4a16e46f164f0ae5"}, + {file = "scipy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28e286bf9ac422d6beb559bc61312c348ca9b0f0dae0d7c5afde7f722d6ea13d"}, + {file = "scipy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:33fde20efc380bd23a78a4d26d59fc8704e9b5fd9b08841693eb46716ba13d86"}, + {file = "scipy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:45c08bec71d3546d606989ba6e7daa6f0992918171e2a6f7fbedfa7361c2de1e"}, + {file = "scipy-1.13.0.tar.gz", hash = "sha256:58569af537ea29d3f78e5abd18398459f195546bb3be23d16677fb26616cc11e"}, ] [package.dependencies] -numpy = ">=1.22.4,<1.29.0" +numpy = ">=1.22.4,<2.3" [package.extras] -dev = ["click", "cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pydevtool", "rich-click", "ruff", "types-psutil", "typing_extensions"] -doc = ["jupytext", "matplotlib (>2)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (==0.9.0)", "sphinx (!=4.1.0)", "sphinx-design (>=0.2.0)"] -test = ["asv", "gmpy2", "hypothesis", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy", "pycodestyle", "pydevtool", "rich-click", "ruff", "types-psutil", "typing_extensions"] +doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.12.0)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0)", "sphinx-design (>=0.4.0)"] +test = ["array-api-strict", "asv", "gmpy2", "hypothesis (>=6.30)", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] [[package]] name = "sentry-sdk" -version = "1.44.0" +version = "1.44.1" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, - {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, + {file = "sentry-sdk-1.44.1.tar.gz", hash = "sha256:24e6a53eeabffd2f95d952aa35ca52f0f4201d17f820ac9d3ff7244c665aaf68"}, + {file = "sentry_sdk-1.44.1-py2.py3-none-any.whl", hash = "sha256:5f75eb91d8ab6037c754a87b8501cc581b2827e923682f593bed3539ce5b3999"}, ] [package.dependencies] @@ -2449,4 +2626,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "ba9c79c168e02744c13818b319b178657930561ce06cc415230033799173eb84" +content-hash = "e255f015493c61e753df9e84ea03a5f3b46cfa8e2933eff283df87f5ffe3babc" diff --git a/pyproject.toml b/pyproject.toml index 6f29d72..c29c438 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ python = "^3.12" python-magic = "<1" python-multipart = "<1" pytz = "*" +redis = {extras = ["hiredis"], version = "^5.0.3"} scikit-learn = "^1.3.2" sentry-sdk = {extras = ["fastapi", "httpx", "sqlalchemy"], version = "^1.44.0"} shapely = "^2.0.2" diff --git a/shell.nix b/shell.nix index f1c3355..1fc1c08 100644 --- a/shell.nix +++ b/shell.nix @@ -26,37 +26,12 @@ let ''; }; - postgres' = pkgs.postgresql_16_jit.withPackages (ps: [ ps.postgis ]); - varnish' = pkgs.varnish; - - supervisordConfig = with pkgs; writeTextFile { - name = "supervisord.conf"; - text = '' - [supervisord] - logfile=data/supervisor/supervisord.log - pidfile=data/supervisor/supervisord.pid - strip_ansi=true - - [program:postgres] - command=${postgres'}/bin/postgres -c config_file=config/postgres.conf -D data/postgres - stopsignal=INT - stdout_logfile=data/supervisor/postgres.log - stderr_logfile=data/supervisor/postgres.log - - [program:varnish] - command=${varnish'}/bin/varnishd -f config/varnish.vcl -s file,data/cache/varnish.bin,2G - stopsignal=INT - stdout_logfile=data/supervisor/varnish.log - stderr_logfile=data/supervisor/varnish.log - ''; - }; - packages' = with pkgs; [ # Base packages wrappedPython coreutils - postgres' - varnish' + (postgresql_16_jit.withPackages (ps: [ ps.postgis ])) + redis # TODO: switch to valkey on new version # Scripts # -- Alembic @@ -80,7 +55,7 @@ let fi if [ ! -f data/postgres/PG_VERSION ]; then - ${postgres'}/bin/initdb -D data/postgres \ + initdb -D data/postgres \ --no-instructions \ --locale=C.UTF-8 \ --encoding=UTF8 \ @@ -91,11 +66,11 @@ let fi mkdir -p data/supervisor - supervisord -c "${supervisordConfig}" + supervisord -c config/supervisord.conf echo "Supervisor started" echo "Waiting for Postgres to start..." - while ! ${postgres'}/bin/pg_isready -q -h 127.0.0.1 -t 10; do sleep 0.1; done + while ! pg_isready -q -h 127.0.0.1 -t 10; do sleep 0.1; done echo "Postgres started, running migrations" alembic-upgrade '') @@ -119,10 +94,10 @@ let (writeShellScriptBin "dev-clean" '' set -e dev-stop - rm -rf data/cache data/postgres + rm -rf data/postgres '') (writeShellScriptBin "dev-logs-postgres" "tail -f data/supervisor/postgres.log") - (writeShellScriptBin "dev-logs-varnish" "tail -f data/supervisor/varnish.log") + (writeShellScriptBin "dev-logs-redis" "tail -f data/supervisor/redis.log") # -- Misc (writeShellScriptBin "make-version" '' diff --git a/utils.py b/utils.py index db8771d..5fef9d6 100644 --- a/utils.py +++ b/utils.py @@ -10,6 +10,13 @@ from config import USER_AGENT +def typed_msgpack_decoder(t: type | None) -> msgspec.msgpack.Decoder: + """ + Create a MessagePack decoder which returns a specific type. + """ + return msgspec.msgpack.Decoder(t) if (t is not None) else msgspec.msgpack.Decoder() + + def typed_json_decoder(t: type | None) -> msgspec.json.Decoder: """ Create a JSON decoder which returns a specific type. @@ -17,6 +24,8 @@ def typed_json_decoder(t: type | None) -> msgspec.json.Decoder: return msgspec.json.Decoder(t) if (t is not None) else msgspec.json.Decoder() +MSGPACK_ENCODE = msgspec.msgpack.Encoder(decimal_format='number', uuid_format='bytes').encode +MSGPACK_DECODE = typed_msgpack_decoder(None).decode JSON_ENCODE = msgspec.json.Encoder(decimal_format='number').encode JSON_DECODE = typed_json_decoder(None).decode From cadc6e0601583bc9097e6bfac48a6fb86fd1ba91 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 14:24:13 +0200 Subject: [PATCH 11/13] Docker listen on all interfaces --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 58e540d..6ef21b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: restart: unless-stopped command: [ + "--host", + "0.0.0.0", "--workers", "${WORKERS:-1}", "--timeout-keep-alive", From ffe4bd5d260c51e1fd9b6cfd0229994fe1706aee Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 14:49:31 +0200 Subject: [PATCH 12/13] Photos migration --- .gitignore | 2 ++ main.py | 4 ++++ services/photo_service.py | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/.gitignore b/.gitignore index 45c215f..f94f547 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,5 @@ pyrightconfig.json data/* cert/* + +.migrate.json diff --git a/main.py b/main.py index 759e9d9..b3408f4 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from middlewares.version_middleware import VersionMiddleware from services.aed_service import AEDService from services.country_service import CountryService +from services.photo_service import PhotoService from services.worker_service import WorkerService @@ -28,6 +29,9 @@ async def lifespan(_): await tg.start(CountryService.update_db_task) await tg.start(AEDService.update_db_task) + # TODO: remove after migration + await PhotoService.migrate() + await worker_state.set_state('running') yield diff --git a/services/photo_service.py b/services/photo_service.py index 10e834f..627aa2f 100644 --- a/services/photo_service.py +++ b/services/photo_service.py @@ -1,16 +1,38 @@ import logging +import pathlib from io import BytesIO from fastapi import UploadFile from PIL import Image, ImageOps from sentry_sdk import trace +from sqlalchemy import select from config import IMAGE_LIMIT_PIXELS, IMAGE_MAX_FILE_SIZE from db import db_read, db_write from models.db.photo import Photo +from utils import JSON_DECODE class PhotoService: + @staticmethod + async def migrate() -> None: + async with db_write() as session: + stmt = select(Photo.id).limit(1) + scalar = await session.scalar(stmt) + if scalar is not None: + return + + logging.info('Migrating photos') + file = pathlib.Path('.migrate.json').read_bytes() + data: list[dict] = JSON_DECODE(file) + for item in data: + photo = Photo( + node_id=int(item['node_id']), + user_id=int(item['user_id']), + ) + photo.id = item['id'] + session.add(photo) + @staticmethod @trace async def get_by_id(id: str, *, check_file: bool = True) -> Photo | None: From 5afe91fcfb228bb26144ec379deb609961a6bcf7 Mon Sep 17 00:00:00 2001 From: Kamil Monicz Date: Wed, 3 Apr 2024 14:59:52 +0200 Subject: [PATCH 13/13] More robust docker restart, Mount migration data --- .github/workflows/deploy.yaml | 1 + docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 2e914e8..1472314 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -104,6 +104,7 @@ jobs: git reset --hard "origin/$tag" echo "Restarting containers" + docker compose down --remove-orphans TAG="$tag" docker compose --env-file "envs/compose/$tag.env" up -d echo "Pruning dangling images" diff --git a/docker-compose.yml b/docker-compose.yml index 6ef21b3..b93a6dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,5 +23,6 @@ services: - ${LISTEN:-80}:8000 volumes: + - ./.migrate.json:/app/.migrate.json - ./data/postgres:/app/data/postgres - /mnt/data/${TAG}/photos:/app/data/photos