From 3904cd003ebec41e47d65b1d9474d79c7db01201 Mon Sep 17 00:00:00 2001 From: qananasikq Date: Wed, 1 Jul 2026 14:35:49 +0300 Subject: [PATCH] Update model translations --- .dockerignore | 23 + .env.example | 28 + .gitattributes | 6 + .gitignore | Bin 0 -> 229 bytes Dockerfile | 30 + README.md | 113 ++ alembic.ini | 40 + alembic/env.py | 58 + alembic/script.py.mako | 25 + alembic/versions/001_initial.py | 79 + alembic/versions/002_add_indexes.py | 29 + alembic/versions/003_add_composite_indexes.py | 38 + alembic/versions/004_widen_string_columns.py | 25 + .../versions/005_unique_images_constraint.py | 33 + alembic/versions/006_image_car_id_bigint.py | 31 + cars_100.txt | 100 + docker-compose.yml | 173 ++ encar_scraper/__init__.py | 1 + encar_scraper/api/__init__.py | 3 + encar_scraper/api/app.py | 40 + encar_scraper/api/deps.py | 9 + encar_scraper/api/routes/__init__.py | 0 encar_scraper/api/routes/cars.py | 115 ++ encar_scraper/api/routes/health.py | 30 + encar_scraper/api/routes/tasks.py | 121 ++ encar_scraper/cli.py | 103 + encar_scraper/core/__init__.py | 1 + encar_scraper/core/config.py | 106 ++ encar_scraper/core/exceptions.py | 2 + encar_scraper/core/logs.py | 32 + encar_scraper/core/runtime_config.py | 97 + encar_scraper/core/utils.py | 72 + encar_scraper/encar.py | 1659 +++++++++++++++++ encar_scraper/partner_dictionary.py | 326 ++++ encar_scraper/storage/__init__.py | 1 + encar_scraper/storage/db.py | 497 +++++ encar_scraper/storage/enums.py | 36 + encar_scraper/storage/models.py | 82 + encar_scraper/storage/schemas.py | 87 + encar_scraper/translations.py | 997 ++++++++++ encar_scraper/worker/__init__.py | 3 + encar_scraper/worker/celery_app.py | 111 ++ encar_scraper/worker/tasks.py | 319 ++++ entrypoint.sh | 7 + pyproject.toml | 43 + runtime_config.json | 105 ++ tests/conftest.py | 7 + tests/test_db.py | 127 ++ tests/test_encar.py | 148 ++ tests/test_load.py | 417 +++++ tests/test_utils.py | 31 + tests/test_worker_tasks.py | 95 + uv.lock | 968 ++++++++++ 53 files changed, 7629 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_initial.py create mode 100644 alembic/versions/002_add_indexes.py create mode 100644 alembic/versions/003_add_composite_indexes.py create mode 100644 alembic/versions/004_widen_string_columns.py create mode 100644 alembic/versions/005_unique_images_constraint.py create mode 100644 alembic/versions/006_image_car_id_bigint.py create mode 100644 cars_100.txt create mode 100644 docker-compose.yml create mode 100644 encar_scraper/__init__.py create mode 100644 encar_scraper/api/__init__.py create mode 100644 encar_scraper/api/app.py create mode 100644 encar_scraper/api/deps.py create mode 100644 encar_scraper/api/routes/__init__.py create mode 100644 encar_scraper/api/routes/cars.py create mode 100644 encar_scraper/api/routes/health.py create mode 100644 encar_scraper/api/routes/tasks.py create mode 100644 encar_scraper/cli.py create mode 100644 encar_scraper/core/__init__.py create mode 100644 encar_scraper/core/config.py create mode 100644 encar_scraper/core/exceptions.py create mode 100644 encar_scraper/core/logs.py create mode 100644 encar_scraper/core/runtime_config.py create mode 100644 encar_scraper/core/utils.py create mode 100644 encar_scraper/encar.py create mode 100644 encar_scraper/partner_dictionary.py create mode 100644 encar_scraper/storage/__init__.py create mode 100644 encar_scraper/storage/db.py create mode 100644 encar_scraper/storage/enums.py create mode 100644 encar_scraper/storage/models.py create mode 100644 encar_scraper/storage/schemas.py create mode 100644 encar_scraper/translations.py create mode 100644 encar_scraper/worker/__init__.py create mode 100644 encar_scraper/worker/celery_app.py create mode 100644 encar_scraper/worker/tasks.py create mode 100644 entrypoint.sh create mode 100644 pyproject.toml create mode 100644 runtime_config.json create mode 100644 tests/conftest.py create mode 100644 tests/test_db.py create mode 100644 tests/test_encar.py create mode 100644 tests/test_load.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_worker_tasks.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6079228 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +__pycache__/ +.pytest_cache/ +.venv/ +venv/ + +.coverage +coverage.xml + +*.pyc +*.pyo +*.pyd +*.log +*.db + +.env +.git/ +.vscode/ + +*.egg-info/ +dist/ +build/ +artifacts/ +tokens_data/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..35d5a3e --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# ===== Encar Scraper Config ===== + +# Логирование +ENCAR_LOG_LEVEL=INFO + +# База данных PostgreSQL +ENCAR_DATABASE_URL=postgresql+psycopg2://encar:encar@postgres:5432/encar_db +ENCAR_DATABASE_ECHO=false +ENCAR_DATABASE_POOL_SIZE=5 +ENCAR_DATABASE_MAX_OVERFLOW=10 +ENCAR_DATABASE_POOL_RECYCLE_SECONDS=1800 + +# Redis +ENCAR_REDIS_URL=redis://redis:6379/1 + +# Celery +CELERY_BROKER_URL=redis://redis:6379/1 +CELERY_RESULT_BACKEND=redis://redis:6379/1 +CELERY_TASK_SOFT_TIME_LIMIT=14400 +CELERY_TASK_TIME_LIMIT=14520 +CELERY_BROKER_VISIBILITY_TIMEOUT=15000 +CELERY_WORKER_CONCURRENCY=4 +CELERY_WORKER_MAX_TASKS_PER_CHILD=50 + +# Encar Beat — полный прогон каждые 60 минут +ENCAR_BEAT_INTERVAL_MINUTES=60 +# 0 = без лимита, парсит ВСЕ авто +ENCAR_BEAT_LIMIT=0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b06c566 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Force LF for shell scripts (critical for Docker on Windows) +*.sh text eol=lf +entrypoint.sh text eol=lf + +# Default for other files +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..d32db7fed2643430a88af8cebbc9599bcc7f55f0 GIT binary patch literal 229 zcmXwz(GG(!2t|1&*^ijmeo|9gbk15#K{x+?=&}b+xP+735VkYL^rR3xy6sdF4fnxf z;)7b82>*-wtOeac_3-#(XHLS4d@~yhPsTi;C|`OIjY{q%Tuqk5nY=yFH1%^G*<+qX zJr4U^vd7qT9%EARn5a-Y2a4K@Rm^*#4wV=68" +RUN pip install --no-cache-dir --no-build-isolation -e . +RUN python -m compileall -q encar_scraper +RUN chmod +x entrypoint.sh +RUN chown -R app:app /app + +STOPSIGNAL SIGINT + +USER app + +# По умолчанию API, но worker/beat переопределяют CMD в docker-compose +ENTRYPOINT ["./entrypoint.sh"] +CMD ["uvicorn", "encar_scraper.api.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9250639 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Encar Scraper + +Парсер автомобилей с [encar.com](https://www.encar.com) + +## Как работает + +Скрапер работает через публичный JSON API Encar (без браузера, без Selenium): + +1. **Листинг** — `api.encar.com/search/car/list/general` отдаёт список авто с пагинацией (до 1000 на страницу) +2. **Шардирование** — API лимитирует выдачу от 10k результатов, поэтому весь каталог 200к+ разбивается на 36 шардов по типу (domestic/import) и диапазонам годов +3. **Фото** — batch endpoint `api.encar.com/v1/readside/vehicles` (до 20 ID за запрос) возвращает все фото с CDN `ci.encar.com` +4. **Переводы** — корейские названия брендов, моделей и цветов автоматически переводятся в русские/английские +5. **Sold-трекинг** — авто, пропавшие из листинга, помечаются как проданные + +Celery Beat запускает полную синхронизацию каждые 60 минут. Worker обходит все шарды, парсит данные и пишет батчами в PostgreSQL. + +## Особенности + +- **Чистые HTTP запросы** — без браузера, через публичный API Encar +- **PostgreSQL** для хранения данных (авто + фото) +- **Celery + Redis** для фоновых задач и периодической синхронизации +- **FastAPI** REST API для управления задачами и просмотра данных + +## Быстрый старт (Docker) + +```bash +docker compose up -d --build +``` + +Поднимутся сервисы: +- `encar-postgres` — PostgreSQL +- `encar-redis` — Redis +- `encar-api` — FastAPI на порту 8000 +- `encar-worker` — Celery worker +- `encar-beat` — периодическая синхронизация (каждые 60 мин) + +## CLI + +```bash +# Инициализация БД +encar init-db + +# Собрать листинг +encar collect-listing --limit 100 + +# Синхронизировать листинг в БД +encar sync-listing --limit 100 --car-type all --only-new true + +# Синхронизировать одно авто +encar sync-vehicle "https://www.encar.com/dc/dc_cardetailview.do?carid=41421262" +``` + +### Фильтры + +```bash +encar sync-listing --car-type import --manufacturer BMW --year-from 2020 --limit 50 +``` + +## API endpoints + +- `GET /health` — проверка доступности +- `GET /api/v1/cars` — список авто с пагинацией и фильтрами +- `GET /api/v1/cars/{car_id}` — детали авто +- `GET /api/v1/cars/by-origin/{origin_id}` — поиск по origin_id +- `POST /api/v1/tasks/sync-vehicle` — синхронизация одного авто +- `POST /api/v1/tasks/sync-listing` — синхронизация листинга +- `GET /api/v1/tasks/{task_id}` — статус задачи +- `GET /api/v1/sync-runs` — история синхронизаций + +### Примеры + +```bash +# Запуск синхронизации +curl -X POST http://localhost:8000/api/v1/tasks/sync-listing \ + -H "Content-Type: application/json" \ + -d '{"car_type": "all", "limit": 100, "only_new": true}' + +# Статус задачи +curl http://localhost:8000/api/v1/tasks/ + +# Список авто +curl "http://localhost:8000/api/v1/cars?page=1&per_page=20" +``` + +## Переменные окружения + +- **БД:** `ENCAR_DATABASE_URL` +- **Redis:** `ENCAR_REDIS_URL` +- **Celery:** `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND`, `CELERY_TASK_TIME_LIMIT` +- **Beat:** `ENCAR_BEAT_INTERVAL_MINUTES` (по умолчанию 60), `ENCAR_BEAT_LIMIT` (0 = без лимита) + +## Структура проекта + +``` +encar_scraper/ +├── encar.py — скрапер Encar (HTTP API), маппер, переводы +├── cli.py — CLI интерфейс +├── api/ +│ ├── app.py — FastAPI приложение +│ └── routes/ — health, cars, tasks +├── core/ +│ ├── config.py — настройки из env vars +│ ├── utils.py — утилиты (save_to_json, deep_find_key) +│ └── logs.py — логирование с trace_id +├── storage/ +│ ├── db.py — PersistenceService (upsert, session_scope) +│ ├── models.py — SQLAlchemy модели (Car, Image, SyncRun) +│ └── schemas.py — Pydantic схемы (CarRecord, ImageRecord) +└── worker/ + ├── celery_app.py — Celery app + beat schedule + └── tasks.py — encar_sync_listing_task, encar_sync_vehicle_task +``` + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..8d1b2a3 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +# Alembic Configuration File + +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+psycopg2://encar:encar@localhost:5434/encar_db + +[loggers] +keys = root,sqlalchemy,alembic + +[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 + +[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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..375e0e3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,58 @@ +# Alembic env.py — подключение к БД через Settings. + +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Добавляем корень проекта в sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from encar_scraper.core.config import Settings +from encar_scraper.storage.models import Base + +config = context.config + +# Logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Подставляем URL из Settings (env vars имеют приоритет) +settings = Settings() +config.set_main_option("sqlalchemy.url", settings.database.url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + # Run migrations in 'offline' mode. + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + # Run migrations in 'online' mode. + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..958df87 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${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 +${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/001_initial.py b/alembic/versions/001_initial.py new file mode 100644 index 0000000..a533529 --- /dev/null +++ b/alembic/versions/001_initial.py @@ -0,0 +1,79 @@ +# Начальная схема: cars, images, sync_runs +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Таблица cars — автомобили с Encar + op.create_table( + "cars", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column("parser_id", sa.String(50), nullable=False, unique=True), + sa.Column("brand", sa.String(50), nullable=False), + sa.Column("model", sa.String(50), nullable=False), + sa.Column("year", sa.Integer, nullable=True), + sa.Column("price", sa.BigInteger, nullable=True), + sa.Column("currency", sa.String(10), nullable=False, server_default="USD"), + sa.Column("mileage", sa.Integer, nullable=False, server_default="0"), + sa.Column("country", sa.String(10), nullable=False, server_default="NA"), + sa.Column("is_sold", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("color", sa.String, nullable=False, server_default="other"), + sa.Column("drive", sa.String(10), nullable=True), + sa.Column("gearbox", sa.String(10), nullable=True), + sa.Column("steering_wheel", sa.String(10), nullable=True), + sa.Column("body_type", sa.String(20), nullable=False, server_default="OTHER"), + sa.Column("engine_volume", sa.Integer, nullable=True), + sa.Column("selling_type", sa.String(20), nullable=False, server_default="NA"), + sa.Column("one_owner", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("new_car", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("is_hidden", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("origin", sa.String(20), nullable=False, server_default="NA"), + sa.Column("origin_url", sa.String, nullable=False), + sa.Column("origin_id", sa.String, nullable=False, unique=True), + sa.Column("is_damaged", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("evaluation", sa.String, nullable=True), + sa.Column("non_smoking", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("rental", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("repair_history", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("slug", sa.String, nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_cars_origin_url", "cars", ["origin_url"]) + op.create_index("ix_cars_origin_id", "cars", ["origin_id"], unique=True) + + # Таблица images — изображения машин + op.create_table( + "images", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column("fullres_image", sa.String, nullable=False), + sa.Column("preview_image", sa.String, nullable=False), + sa.Column("order_index", sa.Integer, nullable=False), + sa.Column("car_id", sa.Integer, sa.ForeignKey("cars.id", ondelete="CASCADE"), nullable=False), + ) + + # Таблица sync_runs — логирование синхронизаций + op.create_table( + "sync_runs", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.Text, nullable=False), + sa.Column("lane", sa.Text, nullable=False), + sa.Column("ids_fetched", sa.Integer, nullable=False, server_default="0"), + sa.Column("cars_upserted", sa.Integer, nullable=False, server_default="0"), + sa.Column("cars_failed", sa.Integer, nullable=False, server_default="0"), + sa.Column("images_upserted", sa.Integer, nullable=False, server_default="0"), + sa.Column("error_summary", sa.Text, nullable=True), + ) + +def downgrade() -> None: + op.drop_table("sync_runs") + op.drop_table("images") + op.drop_table("cars") diff --git a/alembic/versions/002_add_indexes.py b/alembic/versions/002_add_indexes.py new file mode 100644 index 0000000..2c7b631 --- /dev/null +++ b/alembic/versions/002_add_indexes.py @@ -0,0 +1,29 @@ +# Индексы производительности +from typing import Sequence, Union + +from alembic import op + +revision: str = "002_add_indexes" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index("ix_cars_brand", "cars", ["brand"]) + op.create_index("ix_cars_brand_model", "cars", ["brand", "model"]) + op.create_index("ix_cars_year", "cars", ["year"]) + op.create_index("ix_cars_is_sold", "cars", ["is_sold"]) + op.create_index("ix_cars_last_seen_at", "cars", ["last_seen_at"]) + op.create_index("ix_images_car_id", "images", ["car_id"]) + op.create_index("ix_sync_runs_status", "sync_runs", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_sync_runs_status", table_name="sync_runs") + op.drop_index("ix_images_car_id", table_name="images") + op.drop_index("ix_cars_last_seen_at", table_name="cars") + op.drop_index("ix_cars_is_sold", table_name="cars") + op.drop_index("ix_cars_year", table_name="cars") + op.drop_index("ix_cars_brand_model", table_name="cars") + op.drop_index("ix_cars_brand", table_name="cars") diff --git a/alembic/versions/003_add_composite_indexes.py b/alembic/versions/003_add_composite_indexes.py new file mode 100644 index 0000000..5b33f10 --- /dev/null +++ b/alembic/versions/003_add_composite_indexes.py @@ -0,0 +1,38 @@ +# Индексы для масштабированной БД (20k+ записей) +from typing import Sequence, Union + +from alembic import op + +revision: str = "003_add_composite_indexes" +down_revision: Union[str, None] = "002_add_indexes" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Partial index для активных машин Encar (is_sold = FALSE) + # Ускоряет поиск при mark_sold операциях + op.execute( + "CREATE INDEX IF NOT EXISTS ix_cars_active_encar " + "ON cars (origin_url) " + "WHERE is_sold = FALSE AND origin_id LIKE 'encar:%'" + ) + + # Composite index для проверки дубликатов изображений + op.create_index( + "ix_images_car_id_fullres", + "images", + ["car_id", "fullres_image"], + ) + + # Index для быстрого поиска existing машин по origin_url + op.execute( + "CREATE INDEX IF NOT EXISTS ix_cars_origin_url_id " + "ON cars (origin_url, origin_id)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_cars_origin_url_id") + op.drop_index("ix_images_car_id_fullres", table_name="images") + op.execute("DROP INDEX IF EXISTS ix_cars_active_encar") diff --git a/alembic/versions/004_widen_string_columns.py b/alembic/versions/004_widen_string_columns.py new file mode 100644 index 0000000..e83980c --- /dev/null +++ b/alembic/versions/004_widen_string_columns.py @@ -0,0 +1,25 @@ +"""widen parser_id brand model columns + +Revision ID: 004 +Revises: 003_add_composite_indexes +Create Date: 2026-04-15 +""" +from alembic import op +import sqlalchemy as sa + +revision = "004_widen_string_columns" +down_revision = "003_add_composite_indexes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("cars", "parser_id", type_=sa.String(255), existing_nullable=False) + op.alter_column("cars", "brand", type_=sa.String(100), existing_nullable=False) + op.alter_column("cars", "model", type_=sa.String(500), existing_nullable=False) + + +def downgrade() -> None: + op.alter_column("cars", "parser_id", type_=sa.String(50), existing_nullable=False) + op.alter_column("cars", "brand", type_=sa.String(50), existing_nullable=False) + op.alter_column("cars", "model", type_=sa.String(50), existing_nullable=False) diff --git a/alembic/versions/005_unique_images_constraint.py b/alembic/versions/005_unique_images_constraint.py new file mode 100644 index 0000000..b77fc6e --- /dev/null +++ b/alembic/versions/005_unique_images_constraint.py @@ -0,0 +1,33 @@ +"""add unique constraint on images(car_id, fullres_image) + +Revision ID: 005 +Revises: 004_widen_string_columns +Create Date: 2026-04-16 +""" +from alembic import op + +revision = "005_unique_images_constraint" +down_revision = "004_widen_string_columns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Удаляем дубли через CTE + ROW_NUMBER (быстрее чем NOT IN subquery) + op.execute(""" + WITH dupes AS ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY car_id, fullres_image ORDER BY id) AS rn + FROM images + ) + DELETE FROM images WHERE id IN (SELECT id FROM dupes WHERE rn > 1) + """) + op.create_unique_constraint( + "uq_images_car_id_fullres", + "images", + ["car_id", "fullres_image"], + ) + + +def downgrade() -> None: + op.drop_constraint("uq_images_car_id_fullres", "images", type_="unique") diff --git a/alembic/versions/006_image_car_id_bigint.py b/alembic/versions/006_image_car_id_bigint.py new file mode 100644 index 0000000..3cc3373 --- /dev/null +++ b/alembic/versions/006_image_car_id_bigint.py @@ -0,0 +1,31 @@ +"""widen images.car_id from integer to bigint + +Revision ID: 006 +Revises: 005_unique_images_constraint +Create Date: 2026-04-16 +""" +import sqlalchemy as sa +from alembic import op + +revision = "006_image_car_id_bigint" +down_revision = "005_unique_images_constraint" + + +def upgrade() -> None: + op.alter_column( + "images", + "car_id", + existing_type=sa.Integer(), + type_=sa.BigInteger(), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "images", + "car_id", + existing_type=sa.BigInteger(), + type_=sa.Integer(), + existing_nullable=False, + ) diff --git a/cars_100.txt b/cars_100.txt new file mode 100644 index 0000000..75cec4c --- /dev/null +++ b/cars_100.txt @@ -0,0 +1,100 @@ +1. Chevrolet | Spark | 2012 | id=42291468 +2. Mercedes-Benz | G-Class W465 | 2025 | id=42290455 +3. Honda | Civic | 2017 | id=41122992 +4. Honda | Civic | 2017 | id=41122190 +5. Hyundai | Grandeur IG | 2019 | id=42291487 +6. Mercedes-Benz | S-Class W223 | 2021 | id=42289505 +7. Kia | K5 | 2011 | id=42291450 +8. KG Mobility | Very New Tivoli | 2022 | id=41711722 +9. Mercedes-Benz | CLS-Class C257 | 2019 | id=42260678 +10. Kia | Seltos 2nd gen | 2026 | id=42291463 +11. BMW | X7 | 2023 | id=42291443 +12. Hyundai | Sonata | 2022 | id=42291455 +13. Mercedes-Benz | E-Class W213 | 2020 | id=42291456 +14. Hyundai | Grandeur Hybrid | 2025 | id=42291436 +15. Mercedes-Benz | C-Class W205 | 2019 | id=42291451 +16. Hyundai | Santa Fe TM | 2019 | id=42291444 +17. Mercedes-Benz | M-Class W164 | 2007 | id=42257783 +18. Land Rover | Discovery 4 | 2015 | id=42261349 +19. BMW | 3 Series | 2020 | id=42224479 +20. Land Rover | Range Rover Velar | 2019 | id=42273669 +21. Porsche | Cayenne | 2023 | id=42291413 +22. KG Mobility | Very New Tivoli | 2020 | id=42291412 +23. Hyundai | Avante MD | 2011 | id=42291395 +24. Chevrolet | All New Camaro | 2017 | id=42284504 +25. Renault Korea | SM6 | 2017 | id=42278601 +26. Kia | Mohave | 2012 | id=41576139 +27. Kia | Carnival 4th gen | 2023 | id=42291411 +28. Kia | All New K7 | 2016 | id=41951116 +29. Kia | Carnival 4th gen | 2023 | id=42283927 +30. BMW | 3 Series | 2006 | id=42291399 +31. Hyundai | Ioniq 6 | 2023 | id=42291352 +32. Hyundai | New Grand Starex | 2020 | id=41953927 +33. KG Mobility | Korando Sport | 2016 | id=41949895 +34. Renault Korea | New QM6 | 2020 | id=42291394 +35. Mercedes-Benz | E-Class W214 | 2024 | id=42291297 +36. Volvo | S60 | 2016 | id=42291398 +37. BMW | 3 Series | 2017 | id=42291386 +38. Kia | EV6 | 2022 | id=42291336 +39. KG Mobility | 2nd gen | 2026 | id=42291391 +40. Volkswagen | Jetta 7th gen | 2024 | id=42277008 +41. KG Mobility | 2nd gen | 2026 | id=42279398 +42. Chevrolet | Spark | 2012 | id=42291385 +43. Kia | New K9 2nd gen | 2022 | id=42291378 +44. Kia | Carnival 4th gen | 2021 | id=42291381 +45. Audi | A7 | 2015 | id=42291370 +46. Kia | Carnival 4th gen | 2021 | id=42283942 +47. Hyundai | Grandeur HG | 2016 | id=42291377 +48. Kia | Carnival 4th gen | 2021 | id=41916671 +49. Hyundai | New Palisade | 2023 | id=42291375 +50. Porsche | Panamera | 2024 | id=42290634 +51. Genesis | EQ900 | 2018 | id=42291315 +52. Hyundai | | 2015 | id=42291074 +53. Hyundai | Sonata | 2020 | id=42279668 +54. Hyundai | New Grandeur IG Hybrid | 2023 | id=42291312 +55. Kia | New Kia Ray | 2026 | id=42291083 +56. Mercedes-Benz | GLC-Class X253 | 2019 | id=42291373 +57. Hyundai | New Tucson | 2025 | id=42286946 +58. Hyundai | New Palisade | 2023 | id=42291369 +59. BMW | 3 Series | 2017 | id=42291367 +60. Mercedes-Benz | E-Class W213 | 2021 | id=42281942 +61. Kia | K8 | 2023 | id=42291332 +62. KG Mobility | Tivoli Air | 2017 | id=42291259 +63. Renault Korea | SM6 | 2017 | id=42291349 +64. Chevrolet | Cruze | 2014 | id=42291366 +65. Mercedes-Benz | E-Class W212 | 2015 | id=42291345 +66. Kia | K8 Hybrid | 2023 | id=42291363 +67. Renault Korea | NewQM3 | 2019 | id=42291364 +68. Mercedes-Benz | S-Class W222 | 2018 | id=42291350 +69. Hyundai | Palisade | 2021 | id=42291359 +70. Hyundai | Grand Starex | 2018 | id=42291358 +71. Mercedes-Benz | GLS-Class X167 | 2022 | id=42291347 +72. Hyundai | Palisade | 2021 | id=42278631 +73. Peugeot | 2008 | 2016 | id=42289865 +74. Mercedes-Benz | E-Class W213 | 2019 | id=42291344 +75. Hyundai | Grand Starex | 2017 | id=42273594 +76. Kia | All New Sorento | 2015 | id=42273586 +77. Kia | New Morning | 2016 | id=42254096 +78. Hyundai | Santa Fe DM | 2013 | id=42291313 +79. Mercedes-Benz | E-Class W213 | 2019 | id=42287351 +80. Kia | All New K7 | 2017 | id=42291339 +81. Kia | Niro | 2017 | id=42291337 +82. Infiniti | Q30 | 2019 | id=42291333 +83. Kia | Niro | 2017 | id=42289711 +84. Mercedes-Benz | E-Class W213 | 2020 | id=42291331 +85. Ford | Explorer 6th gen | 2023 | id=42291323 +86. Infiniti | Q30 | 2019 | id=42284562 +87. BMW | 3 Series | 2024 | id=42291329 +88. Genesis | GV70 | 2025 | id=42279323 +89. Hyundai | Grandeur HG | 2012 | id=42291328 +90. Mercedes-Benz | E-Class W213 | 2020 | id=42263463 +91. BMW | 3 Series | 2024 | id=42289258 +92. Genesis | GV70 | 2025 | id=42279316 +93. Hyundai | Grandeur HG | 2012 | id=42288803 +94. Kia | Ray | 2014 | id=42291325 +95. Porsche | Cayenne | 2021 | id=42289156 +96. KG Mobility | Tivoli Air | 2018 | id=41570718 +97. Toyota | Crown Crossover | 2024 | id=42291308 +98. Kia | New Sportage R | 2014 | id=42252147 +99. Genesis | GV80 | 2024 | id=42291320 +100. Kia | Ray | 2014 | id=42279563 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47bfaf5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,173 @@ +x-app-env: &app-env + # Database (отдельная БД для Encar, порт 5433) + ENCAR_DATABASE_URL: postgresql+psycopg2://encar:encar@postgres:5432/encar_db + ENCAR_DATABASE_POOL_RECYCLE_SECONDS: ${ENCAR_DATABASE_POOL_RECYCLE_SECONDS:-1800} + # Redis / Celery (порт 6380, db=1) + ENCAR_REDIS_URL: ${ENCAR_REDIS_URL:-redis://redis:6379/1} + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/1} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/1} + CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-14400} + CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-14520} + CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-15000} + CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-50} + # Encar settings + ENCAR_BEAT_INTERVAL_MINUTES: ${ENCAR_BEAT_INTERVAL_MINUTES:-60} + ENCAR_BEAT_LIMIT: ${ENCAR_BEAT_LIMIT:-0} + TZ: ${TZ:-UTC} + +x-env-file: &env-file + - path: .env + required: false + +x-app-service: &app-service + build: . + env_file: *env-file + environment: *app-env + +services: + # PostgreSQL (порт 5434 наружу) + postgres: + image: postgres:16-alpine + container_name: encar-postgres + restart: unless-stopped + environment: + POSTGRES_USER: encar + POSTGRES_PASSWORD: encar + POSTGRES_DB: encar_db + ports: + - "127.0.0.1:5434:5432" + volumes: + - encar_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U encar -d encar_db"] + interval: 5s + timeout: 3s + retries: 5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # Redis (порт 6380 наружу) + redis: + image: redis:7-alpine + container_name: encar-redis + restart: unless-stopped + ports: + - "127.0.0.1:6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + command: > + redis-server + --appendonly yes + --save 60 1000 + volumes: + - encar_redisdata:/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # DB migrations + migrate: + <<: *app-service + container_name: encar-migrate + restart: "no" + depends_on: + postgres: + condition: service_healthy + command: alembic upgrade head + + # FastAPI + api: + <<: *app-service + container_name: encar-api + restart: unless-stopped + ports: + - "127.0.0.1:8001:8000" + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + command: > + uvicorn encar_scraper.api.app:app + --host 0.0.0.0 --port 8000 --workers 1 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # Celery Worker (Encar — только HTTP API, без браузера) + worker: + <<: *app-service + container_name: encar-worker + restart: unless-stopped + init: true + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + stop_grace_period: 30s + command: > + celery -A encar_scraper.worker.celery_app worker + --loglevel=info --concurrency=4 --pool=prefork + --pidfile=/tmp/celery-worker.pid + -Q encar --max-tasks-per-child=50 + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # Celery Beat (периодический планировщик) + beat: + <<: *app-service + container_name: encar-beat + restart: unless-stopped + init: true + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + command: > + celery -A encar_scraper.worker.celery_app beat + --loglevel=info + --pidfile=/tmp/celery-beat.pid + --schedule=/tmp/celerybeat-schedule + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/celery-beat.pid && kill -0 $(cat /tmp/celery-beat.pid)"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + encar_pgdata: + encar_redisdata: diff --git a/encar_scraper/__init__.py b/encar_scraper/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/encar_scraper/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/encar_scraper/api/__init__.py b/encar_scraper/api/__init__.py new file mode 100644 index 0000000..b94a1e8 --- /dev/null +++ b/encar_scraper/api/__init__.py @@ -0,0 +1,3 @@ +from .app import create_app + +__all__ = ["create_app"] diff --git a/encar_scraper/api/app.py b/encar_scraper/api/app.py new file mode 100644 index 0000000..703bd64 --- /dev/null +++ b/encar_scraper/api/app.py @@ -0,0 +1,40 @@ +# Создание FastAPI-приложения и настройка его жизненного цикла. + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from ..core.config import Settings +from ..storage.db import PersistenceService +from .routes import cars, health, tasks + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Жизненный цикл. + # Таблицы создаются через Alembic-миграции (сервис migrate). + yield + + +def create_app(settings: Settings | None = None) -> FastAPI: + _settings = settings or Settings() + + app = FastAPI( + title="Encar Scraper API", + description="REST API для управления задачами скрапинга Encar и просмотра данных", + version="1.0.0", + lifespan=lifespan, + ) + + app.state.settings = _settings + app.state.persistence = PersistenceService(_settings) + + app.include_router(health.router, tags=["health"]) + app.include_router(cars.router, prefix="/api/v1", tags=["cars"]) + app.include_router(tasks.router, prefix="/api/v1", tags=["tasks"]) + + return app + + +# Экземпляр приложения для запуска через uvicorn. +app = create_app() diff --git a/encar_scraper/api/deps.py b/encar_scraper/api/deps.py new file mode 100644 index 0000000..86b4bc3 --- /dev/null +++ b/encar_scraper/api/deps.py @@ -0,0 +1,9 @@ +# Вспомогательные зависимости для FastAPI-роутов. + +from fastapi import Request + +from ..storage.db import PersistenceService + + +def get_persistence(request: Request) -> PersistenceService: + return request.app.state.persistence diff --git a/encar_scraper/api/routes/__init__.py b/encar_scraper/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/encar_scraper/api/routes/cars.py b/encar_scraper/api/routes/cars.py new file mode 100644 index 0000000..4954b23 --- /dev/null +++ b/encar_scraper/api/routes/cars.py @@ -0,0 +1,115 @@ +# Роуты для просмотра автомобилей и агрегированной статистики. + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import selectinload + +from ..deps import get_persistence +from ...storage.db import PersistenceService +from ...storage.models import Car, Image +from ...storage.schemas import CarRead + +router = APIRouter() + + +@router.get("/cars") +def list_cars( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + brand: str | None = None, + model: str | None = None, + year_min: int | None = None, + year_max: int | None = None, + is_sold: bool | None = None, + persistence: PersistenceService = Depends(get_persistence), +): + # Список автомобилей с пагинацией и фильтрами + with persistence.session_scope() as session: + query = select(Car) + count_query = select(func.count(Car.id)) + + if brand: + escaped_brand = brand.replace("%", r"\%").replace("_", r"\_") + cond = Car.brand.ilike(f"%{escaped_brand}%", escape="\\") + query = query.where(cond) + count_query = count_query.where(cond) + if model: + escaped_model = model.replace("%", r"\%").replace("_", r"\_") + cond = Car.model.ilike(f"%{escaped_model}%", escape="\\") + query = query.where(cond) + count_query = count_query.where(cond) + if year_min is not None: + query = query.where(Car.year >= year_min) + count_query = count_query.where(Car.year >= year_min) + if year_max is not None: + query = query.where(Car.year <= year_max) + count_query = count_query.where(Car.year <= year_max) + if is_sold is not None: + query = query.where(Car.is_sold == is_sold) + count_query = count_query.where(Car.is_sold == is_sold) + + total = session.execute(count_query).scalar() or 0 + + offset = (page - 1) * per_page + cars = session.execute( + query.options(selectinload(Car.images)).order_by(Car.last_seen_at.desc()).offset(offset).limit(per_page) + ).scalars().all() + + return { + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if per_page else 0, + "items": [CarRead.model_validate(car).model_dump(mode="json") for car in cars], + } + + +@router.get("/cars/{car_id}") +def get_car( + car_id: int, + persistence: PersistenceService = Depends(get_persistence), +): + # Детальная информация об автомобиле с изображениями + with persistence.session_scope() as session: + car = session.execute( + select(Car).options(selectinload(Car.images)).where(Car.id == car_id) + ).scalars().first() + if car is None: + raise HTTPException(status_code=404, detail="Car not found") + return CarRead.model_validate(car).model_dump(mode="json") + + +@router.get("/cars/by-origin/{origin_id}") +def get_car_by_origin( + origin_id: str, + persistence: PersistenceService = Depends(get_persistence), +): + # Поиск автомобиля по origin_id + with persistence.session_scope() as session: + car = session.execute( + select(Car).options(selectinload(Car.images)).where(Car.origin_id == origin_id) + ).scalars().first() + if car is None: + raise HTTPException(status_code=404, detail="Car not found") + return CarRead.model_validate(car).model_dump(mode="json") + + +@router.get("/stats") +def get_stats(persistence: PersistenceService = Depends(get_persistence)): + # Общая статистика по БД + with persistence.session_scope() as session: + total_cars = session.execute(select(func.count(Car.id))).scalar() or 0 + total_images = session.execute(select(func.count(Image.id))).scalar() or 0 + brands = session.execute( + select(Car.brand, func.count(Car.id)) + .group_by(Car.brand) + .order_by(func.count(Car.id).desc()) + .limit(20) + ).all() + + return { + "total_cars": total_cars, + "total_images": total_images, + "top_brands": [{"brand": b, "count": c} for b, c in brands], + } + diff --git a/encar_scraper/api/routes/health.py b/encar_scraper/api/routes/health.py new file mode 100644 index 0000000..1a347f3 --- /dev/null +++ b/encar_scraper/api/routes/health.py @@ -0,0 +1,30 @@ +# Роут проверки доступности сервиса и соединения с БД. + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy import text + +from ..deps import get_persistence +from ...storage.db import PersistenceService + +router = APIRouter() +logger = logging.getLogger("encar_scraper.api.health") + + +@router.get("/health") +def health_check(persistence: PersistenceService = Depends(get_persistence)): + # Проверка API и БД + db_ok = False + try: + with persistence.session_scope() as session: + session.execute(text("SELECT 1")) + db_ok = True + except Exception: + logger.warning("Health DB check failed", exc_info=True) + + return { + "status": "ok" if db_ok else "degraded", + "service": "encar-scraper-api", + "database": "connected" if db_ok else "unavailable", + } diff --git a/encar_scraper/api/routes/tasks.py b/encar_scraper/api/routes/tasks.py new file mode 100644 index 0000000..6d49a03 --- /dev/null +++ b/encar_scraper/api/routes/tasks.py @@ -0,0 +1,121 @@ +# Роуты запуска задач синхронизации Encar и просмотра истории sync-runs. + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy import select, func + +from ..deps import get_persistence +from ...storage.db import PersistenceService +from ...storage.models import SyncRun +from ...worker.celery_app import celery_app +from ...worker.tasks import encar_sync_listing_task, encar_sync_vehicle_task + +router = APIRouter() + + +class SyncVehicleRequest(BaseModel): + vehicle_url: str + lane: str = "encar" + + +class SyncListingRequest(BaseModel): + car_type: str = "all" # all, domestic, import + manufacturer: str | None = None + year_from: int | None = None + year_to: int | None = None + price_max: int | None = None + lane: str = "encar" + limit: int | None = Field(default=None, le=500_000) + + +@router.post("/tasks/sync-vehicle") +def start_sync_vehicle(body: SyncVehicleRequest): + """Синхронизация одного авто Encar.""" + result = encar_sync_vehicle_task.apply_async( + kwargs={"vehicle_url": body.vehicle_url, "lane": body.lane}, + queue="encar", + ) + return { + "task_id": result.id, + "status": "queued", + "vehicle_url": body.vehicle_url, + } + + +@router.post("/tasks/sync-listing") +def start_sync_listing(body: SyncListingRequest): + """Синхронизация листинга Encar через публичный API.""" + result = encar_sync_listing_task.apply_async( + kwargs={ + "car_type": body.car_type, + "manufacturer": body.manufacturer, + "year_from": body.year_from, + "year_to": body.year_to, + "price_max": body.price_max, + "lane": body.lane, + "limit": body.limit, + }, + queue="encar", + ) + return { + "task_id": result.id, + "status": "queued", + "car_type": body.car_type, + "manufacturer": body.manufacturer, + } + + +@router.get("/tasks/{task_id}") +def get_task_status(task_id: str): + """Статус задачи Celery.""" + result = celery_app.AsyncResult(task_id) + + payload: dict = { + "task_id": task_id, + "state": result.state, + } + + if result.successful(): + payload["result"] = result.result + elif result.failed(): + payload["error"] = str(result.result) + elif result.info is not None: + payload["meta"] = result.info + + return payload + + +@router.get("/sync-runs") +def list_sync_runs( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + persistence: PersistenceService = Depends(get_persistence), +): + """История запусков синхронизации.""" + with persistence.session_scope() as session: + total = session.execute(select(func.count(SyncRun.id))).scalar() or 0 + offset = (page - 1) * per_page + runs = session.execute( + select(SyncRun).order_by(SyncRun.started_at.desc()).offset(offset).limit(per_page) + ).scalars().all() + + return { + "total": total, + "page": page, + "per_page": per_page, + "items": [ + { + "id": run.id, + "started_at": run.started_at.isoformat() if run.started_at else None, + "finished_at": run.finished_at.isoformat() if run.finished_at else None, + "status": run.status, + "lane": run.lane, + "ids_fetched": run.ids_fetched, + "cars_upserted": run.cars_upserted, + "cars_failed": run.cars_failed, + "images_upserted": run.images_upserted, + "error_summary": run.error_summary, + } + for run in runs + ], + } diff --git a/encar_scraper/cli.py b/encar_scraper/cli.py new file mode 100644 index 0000000..03a239f --- /dev/null +++ b/encar_scraper/cli.py @@ -0,0 +1,103 @@ +import argparse +from pathlib import Path + +from .core.config import Settings +from .core.utils import save_to_json +from .encar import EncarScraper, EncarFilters, CAR_TYPE_ALL, CAR_TYPE_DOMESTIC, CAR_TYPE_IMPORT + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Encar scraper CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + default_output_dir = Path("artifacts/json") + + subparsers.add_parser("init-db", help="Create DB tables") + + # collect-listing + collect_parser = subparsers.add_parser("collect-listing", help="Collect Encar listing") + collect_parser.add_argument("--limit", type=int, default=None, help="Max items to collect") + collect_parser.add_argument("--car-type", choices=["all", "domestic", "import"], default="all", + help="all=все, domestic=корейские, import=импорт") + collect_parser.add_argument("--manufacturer", default=None, help="Filter by manufacturer (e.g. BMW, 현대)") + collect_parser.add_argument("--year-from", type=int, default=None, help="Min year") + collect_parser.add_argument("--year-to", type=int, default=None, help="Max year") + collect_parser.add_argument("--price-max", type=int, default=None, help="Max price in 만원 (10k KRW)") + collect_parser.add_argument("--output", default=str(default_output_dir / "encar_listing.json")) + + # scrape-vehicle + scrape_parser = subparsers.add_parser("scrape-vehicle", help="Fetch single vehicle data") + scrape_parser.add_argument("vehicle_url") + scrape_parser.add_argument("--output", default=str(default_output_dir / "encar_vehicle_detail.json")) + + # sync-vehicle + sync_parser = subparsers.add_parser("sync-vehicle", help="Sync single vehicle into DB") + sync_parser.add_argument("vehicle_url") + sync_parser.add_argument("--lane", default="encar") + sync_parser.add_argument("--output", default=str(default_output_dir / "encar_sync_vehicle.json")) + + # sync-listing + sync_listing_parser = subparsers.add_parser("sync-listing", help="Collect listing and sync all to DB") + sync_listing_parser.add_argument("--limit", type=int, default=None, help="Max items to sync") + sync_listing_parser.add_argument("--car-type", choices=["all", "domestic", "import"], default="all") + sync_listing_parser.add_argument("--manufacturer", default=None, help="Filter by manufacturer") + sync_listing_parser.add_argument("--year-from", type=int, default=None) + sync_listing_parser.add_argument("--year-to", type=int, default=None) + sync_listing_parser.add_argument("--price-max", type=int, default=None) + sync_listing_parser.add_argument("--lane", default="encar") + sync_listing_parser.add_argument("--only-new", choices=["true", "false"], default="true") + sync_listing_parser.add_argument("--output", default=str(default_output_dir / "encar_sync_listing.json")) + + return parser + + +def _build_filters_from_args(args) -> EncarFilters: + """Строит фильтры из аргументов CLI.""" + car_type_map = {"all": CAR_TYPE_ALL, "domestic": CAR_TYPE_DOMESTIC, "import": CAR_TYPE_IMPORT} + return EncarFilters( + car_type=car_type_map.get(getattr(args, "car_type", "all"), CAR_TYPE_ALL), + manufacturer=getattr(args, "manufacturer", None), + year_from=getattr(args, "year_from", None), + year_to=getattr(args, "year_to", None), + price_to=getattr(args, "price_max", None), + ) + + +def main() -> None: + import sys + import logging + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stderr, + ) + + parser = build_parser() + args = parser.parse_args() + scraper = EncarScraper() + + if args.command == "init-db": + result = scraper.init_db() + print(f"DB initialized: {result}") + return + elif args.command == "collect-listing": + filters = _build_filters_from_args(args) + result = scraper.collect_listing(limit=args.limit, filters=filters) + elif args.command == "scrape-vehicle": + result = scraper.scrape_vehicle_detail(args.vehicle_url) + elif args.command == "sync-vehicle": + result = scraper.sync_vehicle(args.vehicle_url, lane=args.lane) + elif args.command == "sync-listing": + filters = _build_filters_from_args(args) + only_new = args.only_new == "true" + result = scraper.sync_listing(limit=args.limit, filters=filters, lane=args.lane, only_new=only_new) + else: + raise SystemExit(f"Unknown command: {args.command}") + + save_to_json(result, Path(args.output)) + print(f"Saved to {Path(args.output).resolve()}") + + +if __name__ == "__main__": + main() diff --git a/encar_scraper/core/__init__.py b/encar_scraper/core/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/encar_scraper/core/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/encar_scraper/core/config.py b/encar_scraper/core/config.py new file mode 100644 index 0000000..92d8331 --- /dev/null +++ b/encar_scraper/core/config.py @@ -0,0 +1,106 @@ +import os +from dataclasses import dataclass, field +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + + +TRUE_VALUES = {"1", "true", "yes", "on"} + + +# Хелперы для чтения env-переменных с приведением типов + +def _env_str(name: str, default: str) -> str: + value = os.getenv(name) + return value if value is not None else default + + +def _env_optional_str(name: str) -> str | None: + value = os.getenv(name) + if value is None: + return None + value = value.strip() + return value or None + + +def _env_path_str(name: str) -> str | None: + value = _env_optional_str(name) + if value is None: + return None + return str(Path(value).expanduser()) + + +def _env_bool(name: str, default: bool) -> bool: + fallback = "true" if default else "false" + return _env_str(name, fallback).strip().lower() in TRUE_VALUES + + +def _env_int(name: str, default: int) -> int: + return int(_env_str(name, str(default)).strip()) + + +def _env_float(name: str, default: float) -> float: + return float(_env_str(name, str(default)).strip()) + + +# Конфиг браузерного отпечатка (User-Agent для HTTP запросов) + +@dataclass(slots=True) +class FingerprintConfig: + user_agent: str = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ) + + +# - Конфиг PostgreSQL (URL, пул соединений, pool_recycle) + +@dataclass(slots=True) +class DatabaseConfig: + url: str = _env_str("ENCAR_DATABASE_URL", "postgresql+psycopg2://encar:encar@localhost:5434/encar_db") + echo: bool = _env_bool("ENCAR_DATABASE_ECHO", False) + pool_size: int = _env_int("ENCAR_DATABASE_POOL_SIZE", 5) + max_overflow: int = _env_int("ENCAR_DATABASE_MAX_OVERFLOW", 10) + pool_recycle_seconds: int = _env_int("ENCAR_DATABASE_POOL_RECYCLE_SECONDS", 1800) + auto_create_tables: bool = _env_bool("ENCAR_DATABASE_AUTO_CREATE_TABLES", False) + + +# --- Конфиг Redis (URL для Celery broker) --- + +@dataclass(slots=True) +class RedisConfig: + url: str = _env_str("ENCAR_REDIS_URL", "redis://localhost:6380/1") + + +# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) --- + +@dataclass(slots=True) +class CeleryConfig: + broker_url: str = _env_str("CELERY_BROKER_URL", "") + result_backend: str = _env_str("CELERY_RESULT_BACKEND", "") + task_soft_time_limit: int = _env_int("CELERY_TASK_SOFT_TIME_LIMIT", 14400) + task_time_limit: int = _env_int("CELERY_TASK_TIME_LIMIT", 14520) + worker_concurrency: int = _env_int("CELERY_WORKER_CONCURRENCY", 4) + worker_max_tasks_per_child: int = _env_int("CELERY_WORKER_MAX_TASKS_PER_CHILD", 50) + broker_visibility_timeout: int = _env_int("CELERY_BROKER_VISIBILITY_TIMEOUT", 7200) + # Encar settings + encar_beat_interval_minutes: int = _env_int("ENCAR_BEAT_INTERVAL_MINUTES", 60) + encar_beat_limit: int | None = _env_int("ENCAR_BEAT_LIMIT", 0) or None + + +# --- Главный объект настроек: собирает все блоки конфигурации --- + +@dataclass(slots=True) +class Settings: + log_level: str = _env_str("ENCAR_LOG_LEVEL", "INFO") + log_file: str | None = _env_optional_str("ENCAR_LOG_FILE") + fingerprint: FingerprintConfig = field(default_factory=FingerprintConfig) + database: DatabaseConfig = field(default_factory=DatabaseConfig) + redis: RedisConfig = field(default_factory=RedisConfig) + celery: CeleryConfig = field(default_factory=CeleryConfig) + +# Глобальный синглтон — используется по умолчанию во всех модулях. +settings = Settings() diff --git a/encar_scraper/core/exceptions.py b/encar_scraper/core/exceptions.py new file mode 100644 index 0000000..78fec35 --- /dev/null +++ b/encar_scraper/core/exceptions.py @@ -0,0 +1,2 @@ +class ScraperError(Exception): + """Базовое исключение скрапера.""" diff --git a/encar_scraper/core/logs.py b/encar_scraper/core/logs.py new file mode 100644 index 0000000..32a312d --- /dev/null +++ b/encar_scraper/core/logs.py @@ -0,0 +1,32 @@ +import logging +import sys +from contextvars import ContextVar + +# Храним trace_id текущего потока/корутины. +TRACE_ID: ContextVar[str] = ContextVar("trace_id", default="-") + + +class TraceIdFilter(logging.Filter): + # Добавляет trace_id в каждую запись лога для сквозной трассировки. + def filter(self, record: logging.LogRecord) -> bool: + record.trace_id = TRACE_ID.get() + return True + + +def set_trace_id(trace_id: str) -> None: + TRACE_ID.set(trace_id) + + +def setup_logging(level: str = "INFO", log_file: str | None = None) -> None: + handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)] + if log_file: + handlers.append(logging.FileHandler(log_file, encoding="utf-8")) + trace_filter = TraceIdFilter() + for handler in handlers: + handler.addFilter(trace_filter) + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | trace=%(trace_id)s | %(message)s", + handlers=handlers, + force=True, + ) diff --git a/encar_scraper/core/runtime_config.py b/encar_scraper/core/runtime_config.py new file mode 100644 index 0000000..8ec952c --- /dev/null +++ b/encar_scraper/core/runtime_config.py @@ -0,0 +1,97 @@ +"""Загрузчик runtime_config.json для управления фильтрами синхронизации.""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger("encar_scraper.runtime_config") + +DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent / "runtime_config.json" + + +@dataclass +class SyncConfig: + name: str | None = None + lane: str = "encar_cars" + only_new: bool = False + limit: int | None = None + probe_all_photos: bool = False + + +@dataclass +class FiltersConfig: + price_min: int | None = None + price_max: int | None = None + mileage_min: int | None = None + mileage_max: int | None = None + brands: list[str] = field(default_factory=list) + models: list[str] = field(default_factory=list) + years: list[int] = field(default_factory=list) + body_types: list[str] = field(default_factory=list) + colors: list[str] = field(default_factory=list) + drives: list[str] = field(default_factory=list) + gearboxes: list[str] = field(default_factory=list) + exclude_brands: list[str] = field(default_factory=list) + exclude_models: list[str] = field(default_factory=list) + exclude_years: list[int] = field(default_factory=list) + exclude_body_types: list[str] = field(default_factory=list) + + +@dataclass +class RuntimeConfig: + sync: SyncConfig = field(default_factory=SyncConfig) + filters: FiltersConfig = field(default_factory=FiltersConfig) + + +def load_runtime_config(path: str | Path | None = None) -> RuntimeConfig: + """Загружает runtime_config.json. Если файл не найден — возвращает дефолт.""" + config_path = Path(path) if path else DEFAULT_CONFIG_PATH + if not config_path.exists(): + logger.info("runtime_config.json not found at %s, using defaults", config_path) + return RuntimeConfig() + + try: + raw: dict[str, Any] = json.loads(config_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Failed to parse runtime_config.json: %s, using defaults", exc) + return RuntimeConfig() + + sync_raw = raw.get("sync") or {} + filters_raw = raw.get("filters") or {} + price_raw = filters_raw.get("price") or {} + mileage_raw = filters_raw.get("mileage") or {} + + sync = SyncConfig( + name=sync_raw.get("name"), + lane=sync_raw.get("lane", "encar_cars"), + only_new=bool(sync_raw.get("only_new", False)), + limit=sync_raw.get("limit"), + probe_all_photos=bool(sync_raw.get("probe_all_photos", False)), + ) + + filters = FiltersConfig( + price_min=price_raw.get("min"), + price_max=price_raw.get("max"), + mileage_min=mileage_raw.get("min"), + mileage_max=mileage_raw.get("max"), + brands=[b.lower() for b in (filters_raw.get("brands") or [])], + models=[m.lower() for m in (filters_raw.get("models") or [])], + years=filters_raw.get("years") or [], + body_types=[b.lower() for b in (filters_raw.get("body_types") or [])], + colors=[c.lower() for c in (filters_raw.get("colors") or [])], + drives=[d.lower() for d in (filters_raw.get("drives") or [])], + gearboxes=[g.lower() for g in (filters_raw.get("gearboxes") or [])], + exclude_brands=[b.lower() for b in (filters_raw.get("exclude_brands") or [])], + exclude_models=[m.lower() for m in (filters_raw.get("exclude_models") or [])], + exclude_years=filters_raw.get("exclude_years") or [], + exclude_body_types=[b.lower() for b in (filters_raw.get("exclude_body_types") or [])], + ) + + logger.info( + "runtime_config loaded: lane=%s, limit=%s, brands=%d, exclude_brands=%d", + sync.lane, sync.limit, len(filters.brands), len(filters.exclude_brands), + ) + return RuntimeConfig(sync=sync, filters=filters) diff --git a/encar_scraper/core/utils.py b/encar_scraper/core/utils.py new file mode 100644 index 0000000..df1ecaa --- /dev/null +++ b/encar_scraper/core/utils.py @@ -0,0 +1,72 @@ +import json +import re +from pathlib import Path +from typing import Any, Iterable + + +def save_to_json(data: Any, filename: str | Path) -> None: + path = Path(filename) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def first_non_empty(values: Iterable[Any]) -> Any | None: + for value in values: + if value not in (None, "", [], {}, ()): + return value + return None + + +# Регулярные выражения для VIN, lot и price. +VIN_RE = re.compile(r"\b([A-HJ-NPR-Z0-9]{17})\b", re.IGNORECASE) +LOT_RE = re.compile(r"\b(\d{7,10})\b") +PRICE_RE = re.compile(r"\$\s?([\d,]+(?:\.\d{1,2})?)") + + +def deep_find_key(obj, target_keys: set[str], max_depth: int = 64, _depth: int = 0) -> list: + # Рекурсивно ищет значения по набору ключей в произвольном JSON-дереве. + found = [] + if _depth >= max_depth: + return found + if isinstance(obj, dict): + for key, value in obj.items(): + if key.lower() in target_keys: + found.append(value) + found.extend(deep_find_key(value, target_keys, max_depth=max_depth, _depth=_depth + 1)) + elif isinstance(obj, list): + for item in obj: + found.extend(deep_find_key(item, target_keys, max_depth=max_depth, _depth=_depth + 1)) + return found + + +def deep_find_all_keys( + payloads: list, + field_map: dict[str, set[str]], + max_depth: int = 64, +) -> dict[str, list]: + """Извлекает все нужные поля за один проход по JSON.""" + # Готовим обратную карту: нормализованный ключ -> имя поля. + reverse: dict[str, str] = {} + for field_name, keys in field_map.items(): + for k in keys: + reverse[k.lower()] = field_name + + result: dict[str, list] = {f: [] for f in field_map} + + def _recurse(obj: Any, depth: int) -> None: + if depth >= max_depth: + return + if isinstance(obj, dict): + for k, v in obj.items(): + field = reverse.get(k.lower()) + if field is not None: + result[field].append(v) + _recurse(v, depth + 1) + elif isinstance(obj, list): + for item in obj: + _recurse(item, depth + 1) + + for payload in payloads: + _recurse(payload, 0) + + return result diff --git a/encar_scraper/encar.py b/encar_scraper/encar.py new file mode 100644 index 0000000..523dbe1 --- /dev/null +++ b/encar_scraper/encar.py @@ -0,0 +1,1659 @@ +import json +import logging +import re +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, quote +from urllib.request import Request, urlopen + +import urllib3 + +from .core.config import Settings +from .core.runtime_config import FiltersConfig +from .storage.db import PersistenceService +from .storage.schemas import CarRecord, ImageRecord +from .translations import ( + BODY_TYPE_MAP, + BRAND_ENGLISH_ALIASES, + BRAND_TRANSLATIONS, + CITY_TO_COUNTRY, + COLOR_TRANSLATIONS, + FUEL_TYPE_MAP, + GEARBOX_MAP_KO, + MODEL_BODY_TYPE_MAP, + MODEL_EXACT_TRANSLATIONS, + MODEL_PHRASE_TRANSLATIONS, + MODEL_TOKEN_TRANSLATIONS, + MODEL_TRANSLATIONS, + SELL_TYPE_MAP, + expand_allowed_brands, +) + +logger = logging.getLogger("encar_scraper") + +ENCAR_LISTING_API = "https://api.encar.com/search/car/list/general" +ENCAR_DETAIL_API_TEMPLATE = "https://api.encar.com/v1/readside/vehicle/{vehicle_id}" +ENCAR_PHOTO_API_TEMPLATE = "https://api.encar.com/legacy/usedcar/sale/car/photo/{vehicle_id}" +# Batch endpoint из фронтенд JS (fem.encar.com) — до 20 машин за запрос, все фото +ENCAR_BATCH_VEHICLES_API = "https://api.encar.com/v1/readside/vehicles" +BATCH_VEHICLES_CHUNK_SIZE = 20 +ENCAR_DETAIL_URL_TEMPLATE = "https://www.encar.com/dc/dc_cardetailview.do?carid={vehicle_id}" +ENCAR_IMAGE_BASE = "https://ci.encar.com" +VEHICLE_ID_RE = re.compile(r"(?:carid|vehicleId)=?(\d+)") +NUMERIC_ID_RE = re.compile(r"(\d{6,})") +PHOTO_VEHICLE_ID_RE = re.compile(r"/(\d+)_\d+\.(?:jpg|jpeg|png|webp)$", re.IGNORECASE) + +# Максимальный номер фото для пробинга и допустимые промахи подряд +PHOTO_PROBE_MAX = 50 +PHOTO_PROBE_MAX_CONSECUTIVE_MISS = 7 + +# Типы листинга +CAR_TYPE_ALL = "A" # все (отечественные + импорт) +CAR_TYPE_DOMESTIC = "Y" # только отечественные +CAR_TYPE_IMPORT = "N" # только импорт +CAR_TYPE_ELECTRIC = "G" # электро/гибрид + +# Regex для парсинга объёма двигателя из Badge +ENGINE_VOLUME_RE = re.compile(r"(\d+\.\d+)\s*(?:T|터보)?") +# Regex для европейских кодов: "530i", "C200", "A220", "GLC300", "740d", "M340i", "S63", "120i", "E220d" и т.д. +# Ищем 3-значные числа в начале или после буквы: 530, 200, 220, 300, 740, 340 ... +EURO_ENGINE_CODE_RE = re.compile( + r"(?:^|[A-Za-z])(\d{3})(?:[a-zA-Z]|$|\s)" # C200, A220, 530i, 740d, etc. +) +# Маппинг европейских кодов → объём (cc). Первые 1-2 цифры = литраж +EURO_CODE_ENGINE_MAP: dict[str, int] = { + # BMW + "118": 1500, "120": 2000, "125": 2000, "128": 2000, + "218": 1500, "220": 2000, "225": 2000, "228": 2000, "230": 2000, "235": 2000, + "318": 1500, "320": 2000, "325": 2500, "328": 2000, "330": 2000, "335": 3000, "340": 3000, + "418": 2000, "420": 2000, "425": 2000, "428": 2000, "430": 2000, "435": 3000, "440": 3000, + "520": 2000, "525": 2500, "528": 2000, "530": 2000, "535": 3000, "540": 3000, + "620": 2000, "630": 3000, "640": 3000, "650": 4400, + "725": 2500, "730": 3000, "740": 3000, "745": 3000, "750": 4400, "760": 6600, + "X20": 2000, "X25": 2500, "X28": 2000, "X30": 2000, "X35": 3000, "X40": 3000, "X50": 4400, + # Mercedes-Benz + "160": 1600, "180": 1600, "200": 2000, "220": 2000, "250": 2000, "260": 2000, + "280": 3000, "300": 3000, "350": 3500, "400": 3000, "450": 3000, + "500": 4000, "550": 4700, "560": 5500, "600": 6000, "630": 6300, + "43": 3000, "53": 3000, "55": 5500, "63": 4000, + # Audi + "25": 1400, "30": 2000, "35": 2000, "40": 2000, "45": 2000, "50": 3000, "55": 3000, +} + +# Маппинг для извлечения engine_volume из числовых кодов типа "M16 GDI" +BADGE_SHORTCODE_RE = re.compile(r"M(\d{2})\s") # M16 GDI → 1600 + +@dataclass +class EncarFilters: + """Фильтры для API запросов.""" + car_type: str = CAR_TYPE_ALL # A=все, Y=корейские, N=импорт + manufacturer: str | None = None # BMW, 현대, etc. + model: str | None = None + year_from: int | None = None + year_to: int | None = None + price_from: int | None = None # в 만원 (10k KRW) + price_to: int | None = None + mileage_max: int | None = None + + def build_query(self) -> str: + """Строит строку запроса для API.""" + parts = ["Hidden.N"] + + if self.car_type: + parts.append(f"CarType.{self.car_type}") + + if self.manufacturer: + parts.append(f"Manufacturer.{self.manufacturer}") + + if self.model: + parts.append(f"Model.{self.model}") + + # Год: Year в формате YYYYMM (напр. 202001) + # year_from/year_to > 9999 → уже в формате YYYYMM, иначе YYYY → прибавляем 01/12 + if self.year_from or self.year_to: + if self.year_from and self.year_from > 9999: + year_min = str(self.year_from) + else: + year_min = f"{self.year_from}01" if self.year_from else "" + if self.year_to and self.year_to > 9999: + year_max = str(self.year_to) + else: + year_max = f"{self.year_to}12" if self.year_to else "" + parts.append(f"Year.range({year_min}..{year_max})") + + # Цена в 만원 + if self.price_from or self.price_to: + p_min = str(self.price_from) if self.price_from else "" + p_max = str(self.price_to) if self.price_to else "" + parts.append(f"Price.range({p_min}..{p_max})") + + # Пробег + if self.mileage_max: + parts.append(f"Mileage.range(..{self.mileage_max})") + + return "(And." + "._." .join(parts) + ".)" + + +@dataclass(slots=True, frozen=True) +class RuntimeFilterSpec: + price_min: int | None = None + price_max: int | None = None + mileage_min: int | None = None + mileage_max: int | None = None + models: frozenset[str] = frozenset() + years: frozenset[int] = frozenset() + body_types: frozenset[str] = frozenset() + colors: frozenset[str] = frozenset() + drives: frozenset[str] = frozenset() + gearboxes: frozenset[str] = frozenset() + exclude_models: frozenset[str] = frozenset() + exclude_years: frozenset[int] = frozenset() + exclude_body_types: frozenset[str] = frozenset() + + +@dataclass(slots=True) +class EncarMapper: + def map_to_car_record(self, vehicle_url: str, payload: dict[str, Any], probe_all_photos: bool = False) -> CarRecord: + vehicle_id = self._resolve_vehicle_id(vehicle_url, payload) + origin_id = f"encar:{vehicle_id}" + parser_id = f"encar:{vehicle_id}" + url_vehicle_id = self._extract_vehicle_id(vehicle_url) + if vehicle_url and url_vehicle_id == vehicle_id: + origin_url = vehicle_url + else: + origin_url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=vehicle_id) + + brand = self._as_str(payload.get("Manufacturer") or payload.get("Brand") or "UNKNOWN") + # Перевод бренда на русский + brand = BRAND_TRANSLATIONS.get(brand, brand) + + # Нужна только базовая модель (без trim/sub-trim) + model_raw = self._as_str(payload.get("Model") or "UNKNOWN") + badge = self._as_str(payload.get("Badge") or "") + badge_detail = self._as_str(payload.get("BadgeDetail") or "") + model = self._translate_model(model_raw) + + year = self._to_year(payload.get("FormYear") or payload.get("Year")) + # Price в 만원 (10 000 KRW) → переводим в KRW + price_man_won = self._to_float(payload.get("Price")) + price = int(price_man_won * 10_000) if price_man_won is not None else None + mileage = self._to_int(payload.get("Mileage")) + + # Цвет: листинг не содержит цвет, проверяем все варианты + color_raw = self._as_str( + payload.get("ExteriorColor") or payload.get("Color") or "" + ) + color = self._translate_color(color_raw) if color_raw else "other" + + currency = "KRW" + country = "KR" + + # Топливо и коробка передач + fuel_raw = self._as_str(payload.get("FuelType") or "") + green_type = self._as_str(payload.get("GreenType") or "") + + # Gearbox: Encar листинг не даёт Transmission, определяем по топливу + if fuel_raw == "전기": + # Чистый электромобиль + gearbox = "EV" + else: + # Гибрид (가솔린+전기, 디젤+전기) или ДВС → AT + gearbox_raw = self._as_str(payload.get("Transmission") or "") + if gearbox_raw: + gearbox = GEARBOX_MAP_KO.get(gearbox_raw) or GEARBOX_MAP_KO.get(gearbox_raw.lower()) or "AT" + else: + gearbox = "AT" + + sell_type_raw = self._as_str(payload.get("SellType") or "") + selling_type = SELL_TYPE_MAP.get(sell_type_raw, "STOCK") + + # Парсинг drive из Badge + drive = self._parse_drive(badge, model_raw) + + # Парсинг body_type из Model (по ключевым словам в модели) + body_type_raw = self._as_str(payload.get("BodyType") or "") + if body_type_raw: + body_type = BODY_TYPE_MAP.get(body_type_raw, "OTHER") + else: + body_type = self._detect_body_type(model_raw) + + # Парсинг engine_volume из Badge (напр. "가솔린 2.5 터보 AWD" → 2500) + engine_volume_raw = payload.get("Displacement") or payload.get("EngineCc") + if engine_volume_raw: + engine_volume = self._to_int(engine_volume_raw) + else: + engine_volume = self._parse_engine_volume(badge) + + # "엔카진단" флаги + service_marks = payload.get("ServiceMark") or [] + condition_list = payload.get("Condition") or [] + has_inspection = bool(condition_list) or ( + "EncarDiagnosisP1" in service_marks or "EncarDiagnosisP2" in service_marks + ) + + photo_prefix = self._as_str(payload.get("Photo") or "") + images = self._map_images( + payload.get("Photos") or [], + probe_all=probe_all_photos, + photo_prefix=photo_prefix or None, + ) + + return CarRecord( + parser_id=parser_id, + brand=brand, + model=model, + year=year, + price=price, + currency=currency, + mileage=mileage or 0, + country=country, + is_sold=False, + color=color or "other", + drive=drive, + gearbox=gearbox, + steering_wheel="LEFT", # Корея — левый руль + body_type=body_type, + engine_volume=engine_volume, + selling_type=selling_type, + one_owner=False, + new_car=False, + is_hidden=False, + origin="ENCAR", + origin_url=origin_url, + origin_id=origin_id, + is_damaged=False, + evaluation="diagnosis" if has_inspection else None, + non_smoking=False, + rental=False, + repair_history=False, + slug=self._build_slug(brand, model, vehicle_id), + images=images, + ) + + def _extract_vehicle_id(self, vehicle_url: str | None) -> str | None: + if not vehicle_url: + return None + match = VEHICLE_ID_RE.search(vehicle_url) + return match.group(1) if match else None + + def _resolve_vehicle_id(self, vehicle_url: str | None, payload: dict[str, Any]) -> str: + # 1) Из URL (carid=123...) + from_url = self._extract_vehicle_id(vehicle_url) + if from_url: + return from_url + + # 2) Из явных полей payload + for key in ("Id", "id", "vehicleId", "VehicleId", "carId", "carid"): + value = payload.get(key) + extracted = self._extract_numeric_id(value) + if extracted: + return extracted + + # 3) Из фото-пути (например /.../41854374_001.jpg) + photos = payload.get("Photos") or [] + if isinstance(photos, list): + for raw in photos: + if not isinstance(raw, dict): + continue + location = raw.get("location") or raw.get("Location") or "" + extracted = self._extract_numeric_id(location) + if extracted: + return extracted + + # 4) Фолбэк: любой id-like ключ в payload + fallback = self._find_any_numeric_id(payload) + if fallback: + return fallback + + raise ValueError("Cannot resolve numeric Encar vehicle id") + + def _extract_numeric_id(self, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, int): + return str(value) + text = self._as_str(value) + if not text: + return None + match = NUMERIC_ID_RE.search(text) + return match.group(1) if match else None + + def _find_any_numeric_id(self, payload: dict[str, Any]) -> str | None: + stack: list[Any] = [payload] + while stack: + current = stack.pop() + if isinstance(current, dict): + for key, value in current.items(): + if isinstance(key, str) and "id" in key.lower(): + extracted = self._extract_numeric_id(value) + if extracted: + return extracted + if isinstance(value, (dict, list)): + stack.append(value) + elif isinstance(current, list): + for item in current: + if isinstance(item, (dict, list)): + stack.append(item) + return None + + def _build_slug(self, brand: str, model: str, vehicle_id: str) -> str: + raw = re.sub(r"[^a-z0-9]+", "-", f"{brand}-{model}-{vehicle_id}".lower()).strip("-") + return raw or f"encar-{vehicle_id}" + + def _as_str(self, value: Any) -> str: + return str(value).strip() if value is not None else "" + + def _translate_text(self, text: str) -> str: + translated = self._as_str(text) + for source, target in sorted(MODEL_TRANSLATIONS.items(), key=lambda item: len(item[0]), reverse=True): + translated = translated.replace(source, target) + translated = re.sub(r"\s+", " ", translated).strip() + return translated + + def _has_korean(self, text: str) -> bool: + return bool(re.search(r"[가-힣]", text)) + + def _normalize_model_text(self, text: str) -> str: + normalized = self._as_str(text) + if not normalized: + return normalized + # На оригинальном сайте Encar скобок нет — убираем скобки вместе с содержимым. + normalized = re.sub(r"[\(\[(【][^)\])】]*[\)\])】]?", " ", normalized) + normalized = normalized.replace("·", " ").replace("/", " ") + normalized = re.sub(r"(?<=[A-Za-z])-클래스", " 클래스", normalized) + normalized = re.sub(r"(?<=[0-9])시리즈", "시리즈", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + def _translate_model(self, text: str) -> str: + """Безопасно переводит модель Encar: точные совпадения + корейские фрагменты. + + Старый общий replace ломал смешанные названия. Здесь английские строки + возвращаются как есть, а короткие корейские токены заменяются только + когда они реально присутствуют как отдельный смысловой фрагмент. + """ + original = self._normalize_model_text(text) + if not original or not self._has_korean(original): + return original + + exact = MODEL_EXACT_TRANSLATIONS.get(original) + if exact: + return exact + + translated = original + + # Сначала длинные точные модели и фразы — чтобы "렉с턴 스포츠 칸" + # не превратился в "Rexton Sport 칸". + combined = {**MODEL_EXACT_TRANSLATIONS, **MODEL_PHRASE_TRANSLATIONS, **MODEL_TRANSLATIONS} + for source, target in sorted(combined.items(), key=lambda item: len(item[0]), reverse=True): + if source in translated: + # Заменяем только когда корейский ключ стоит как отдельный + # смысловой фрагмент (границы — не корейские символы). Иначе + # короткий ключ "레이" (Ray) резал бы "슈팅브레이크" → "슈팅브Ray크". + pattern = rf"(? str: + value = self._as_str(color_raw) + if not value: + return "other" + return COLOR_TRANSLATIONS.get(value, self._translate_text(value).lower() or "other") + + def _to_float(self, value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (ValueError, TypeError): + digits = re.sub(r"[^0-9.]", "", str(value)) + return float(digits) if digits else None + + def _to_int(self, value: Any) -> int | None: + f = self._to_float(value) + return int(f) if f is not None else None + + def _to_year(self, value: Any) -> int | None: + """Парсинг года. + - FormYear: "2021" → 2021 + - Year: 202102.0 → YYYYMM формат → берём YYYY часть + """ + if value is None: + return None + try: + s = str(value).strip().split(".")[0] # убираем .0 + if len(s) >= 6: + # YYYYMM формат + return int(s[:4]) + if len(s) == 4: + return int(s) + except (ValueError, TypeError): + pass + return None + + def _map_images(self, photos: Any, probe_all: bool = False, photo_prefix: str | None = None) -> list[ImageRecord]: + if not isinstance(photos, list): + photos = [] + images: list[ImageRecord] = [] + known_orders: set[int] = set() + url_prefix: str | None = None + + for raw in photos: + if not isinstance(raw, dict): + continue + location = raw.get("location") or raw.get("Location") or "" + if not location: + continue + url = self._build_image_url(str(location)) + order = int(raw.get("ordering") or 0) + images.append(ImageRecord(fullres_image=url, preview_image=url, order_index=order)) + known_orders.add(order) + if url_prefix is None and "_" in location: + url_prefix = self._build_image_url(location.rsplit("_", 1)[0]) + + # Если Photos не дал prefix, используем Photo (путь-префикс из premium API) + if url_prefix is None and photo_prefix: + url_prefix = self._build_image_url(photo_prefix.rstrip("_")) + + if probe_all and url_prefix: + extra = self._probe_additional_photos(url_prefix, known_orders) + images.extend(extra) + + images.sort(key=lambda item: item.order_index) + return images + + @staticmethod + def _head_check(url: str) -> bool: + """Проверка существования фото через HEAD-запрос.""" + try: + req = Request(url, method="HEAD", headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.encar.com/", + }) + with urlopen(req, timeout=3) as resp: + return resp.status == 200 + except (HTTPError, URLError, OSError): + return False + + @staticmethod + def _head_check_pooled(pool: urllib3.HTTPSConnectionPool, path: str) -> bool: + """HEAD-запрос через connection pool (быстрее за счёт reuse TCP/SSL).""" + try: + resp = pool.request( + "HEAD", path, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.encar.com/", + }, + timeout=2.0, + retries=False, + ) + return resp.status == 200 + except Exception: + return False + + def _probe_additional_photos(self, url_prefix: str, known_orders: set[int]) -> list[ImageRecord]: + """Пробинг дополнительных фото параллельно через HEAD-запросы.""" + candidates = [] + for i in range(1, PHOTO_PROBE_MAX + 1): + if i in known_orders: + continue + candidates.append((i, f"{url_prefix}_{i:03d}.jpg")) + + if not candidates: + return [] + + found: list[ImageRecord] = [] + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(self._head_check, url): (order, url) for order, url in candidates} + for future in as_completed(futures): + order, url = futures[future] + if future.result(): + found.append(ImageRecord(fullres_image=url, preview_image=url, order_index=order)) + + found.sort(key=lambda x: x.order_index) + + # Отсекаем хвост после PHOTO_PROBE_MAX_CONSECUTIVE_MISS промахов подряд + all_found_orders = known_orders | {img.order_index for img in found} + cutoff = PHOTO_PROBE_MAX + 1 + consecutive_miss = 0 + for i in range(1, PHOTO_PROBE_MAX + 1): + if i in all_found_orders: + consecutive_miss = 0 + else: + consecutive_miss += 1 + if consecutive_miss >= PHOTO_PROBE_MAX_CONSECUTIVE_MISS: + cutoff = i - PHOTO_PROBE_MAX_CONSECUTIVE_MISS + 1 + break + + return [img for img in found if img.order_index < cutoff] + + def _build_image_url(self, location: str) -> str: + if location.startswith("http"): + return location + if location.startswith("/"): + return f"{ENCAR_IMAGE_BASE}{location}" + return f"{ENCAR_IMAGE_BASE}/{location}" + + def _parse_drive(self, badge: str, model: str) -> str | None: + """Парсит тип привода из Badge и Model.""" + text = f"{badge} {model}".lower() + if "awd" in text or "4wd" in text or "사륜" in text or "4륜" in text: + return "4WD" + elif "2wd" in text or "fwd" in text or "이륜" in text or "전륜" in text: + return "FWD" + elif "rwd" in text or "후륜" in text: + return "RWD" + return None + + def _parse_engine_volume(self, badge: str) -> int | None: + """Парсит объём двигателя из Badge. + + Примеры: + "가솔린 2.5 터보 AWD" → 2500 + "1.6 GDI 유니크" → 1600 + "3.8 4WD" → 3800 + "530i 럭셔리" → 2000 (BMW code) + "C200 AMG Line" → 2000 (Mercedes code) + "롱레인지 AWD" → None (электро, нет объёма) + """ + if not badge: + return None + + # 1. Прямой литраж: "2.5", "1.6", "3.8" + m = ENGINE_VOLUME_RE.search(badge) + if m: + vol = float(m.group(1)) + if 0.5 <= vol <= 8.0: + return int(vol * 1000) + + # 2. Европейские коды: "530i", "C200", "GLC300", "M340i", "E220d" + euro_match = EURO_ENGINE_CODE_RE.search(badge) + if euro_match: + code = euro_match.group(1) + if code in EURO_CODE_ENGINE_MAP: + return EURO_CODE_ENGINE_MAP[code] + + # 3. Корейские shortcode: "M16 GDI" → 1600 + m_short = BADGE_SHORTCODE_RE.search(badge) + if m_short: + code = int(m_short.group(1)) + if 10 <= code <= 60: + return code * 100 + + return None + + def _detect_body_type(self, model_raw: str) -> str: + """Определяет тип кузова из названия модели.""" + if not model_raw: + return "OTHER" + # Проверяем каждый ключ из MODEL_BODY_TYPE_MAP + for keyword, body in MODEL_BODY_TYPE_MAP.items(): + if keyword in model_raw: + return body + return "OTHER" + + +class EncarScraper: + DEFAULT_PAGE_SIZE = 1000 # API поддерживает до 1000 + + def __init__(self, settings: Settings | None = None, persistence: PersistenceService | None = None) -> None: + self.settings = settings or Settings() + self.persistence = persistence or PersistenceService(self.settings) + self.car_mapper = EncarMapper() + self._batch_pool: urllib3.HTTPSConnectionPool | None = None + + def _get_batch_pool(self) -> urllib3.HTTPSConnectionPool: + """Возвращает переиспользуемый connection pool для batch photo API.""" + if self._batch_pool is None: + self._batch_pool = urllib3.HTTPSConnectionPool( + "api.encar.com", + port=443, + maxsize=30, + block=True, + headers={ + "User-Agent": self.settings.fingerprint.user_agent, + "Accept": "application/json", + }, + cert_reqs="CERT_REQUIRED", + ) + return self._batch_pool + + @staticmethod + def _compile_runtime_filters(runtime_filters: FiltersConfig | None) -> RuntimeFilterSpec | None: + if runtime_filters is None: + return None + + spec = RuntimeFilterSpec( + price_min=runtime_filters.price_min, + price_max=runtime_filters.price_max, + mileage_min=runtime_filters.mileage_min, + mileage_max=runtime_filters.mileage_max, + models=frozenset(m for m in runtime_filters.models if m), + years=frozenset(int(y) for y in runtime_filters.years if y is not None), + body_types=frozenset(b for b in runtime_filters.body_types if b), + colors=frozenset(c for c in runtime_filters.colors if c), + drives=frozenset(d for d in runtime_filters.drives if d), + gearboxes=frozenset(g for g in runtime_filters.gearboxes if g), + exclude_models=frozenset(m for m in runtime_filters.exclude_models if m), + exclude_years=frozenset(int(y) for y in runtime_filters.exclude_years if y is not None), + exclude_body_types=frozenset(b for b in runtime_filters.exclude_body_types if b), + ) + + if not any(( + spec.price_min is not None, + spec.price_max is not None, + spec.mileage_min is not None, + spec.mileage_max is not None, + spec.models, + spec.years, + spec.body_types, + spec.colors, + spec.drives, + spec.gearboxes, + spec.exclude_models, + spec.exclude_years, + spec.exclude_body_types, + )): + return None + + return spec + + @staticmethod + def _matches_any_token(value: str, tokens: frozenset[str]) -> bool: + if not tokens: + return True + return any(token in value for token in tokens if token) + + def _record_passes_runtime_filters( + self, + record: CarRecord, + runtime_filters: RuntimeFilterSpec | None, + ) -> bool: + if runtime_filters is None: + return True + + model_lower = (record.model or "").lower() + body_type_lower = (record.body_type or "").lower() + color_lower = (record.color or "").lower() + drive_lower = (record.drive or "").lower() + gearbox_lower = (record.gearbox or "").lower() + + if runtime_filters.price_min is not None and (record.price is None or record.price < runtime_filters.price_min): + return False + if runtime_filters.price_max is not None and (record.price is None or record.price > runtime_filters.price_max): + return False + if runtime_filters.mileage_min is not None and record.mileage < runtime_filters.mileage_min: + return False + if runtime_filters.mileage_max is not None and record.mileage > runtime_filters.mileage_max: + return False + + if runtime_filters.models and not self._matches_any_token(model_lower, runtime_filters.models): + return False + if runtime_filters.exclude_models and self._matches_any_token(model_lower, runtime_filters.exclude_models): + return False + + if runtime_filters.years and (record.year is None or record.year not in runtime_filters.years): + return False + if runtime_filters.exclude_years and record.year in runtime_filters.exclude_years: + return False + + if runtime_filters.body_types and body_type_lower not in runtime_filters.body_types: + return False + if runtime_filters.exclude_body_types and body_type_lower in runtime_filters.exclude_body_types: + return False + + if runtime_filters.colors and color_lower not in runtime_filters.colors: + return False + if runtime_filters.drives and drive_lower not in runtime_filters.drives: + return False + if runtime_filters.gearboxes and gearbox_lower not in runtime_filters.gearboxes: + return False + + return True + + def _canonical_vehicle_id_from_images(self, record: CarRecord) -> str | None: + current_vehicle_id = self._extract_vehicle_id(record.origin_url or "") + if not current_vehicle_id: + current_vehicle_id = record.origin_id.split(":", 1)[-1] if ":" in record.origin_id else record.origin_id + + image_vehicle_ids: list[str] = [] + for image in record.images: + url = image.fullres_image or "" + match = PHOTO_VEHICLE_ID_RE.search(url) + if match: + image_vehicle_ids.append(match.group(1)) + + if not image_vehicle_ids: + return None + + canonical_vehicle_id, occurrences = Counter(image_vehicle_ids).most_common(1)[0] + if canonical_vehicle_id == current_vehicle_id: + return None + + # Канонизируем только если mismatch подтверждается минимум двумя фото. + if occurrences < 2: + return None + + return canonical_vehicle_id + + def _apply_canonical_vehicle_id(self, record: CarRecord) -> bool: + canonical_vehicle_id = self._canonical_vehicle_id_from_images(record) + if not canonical_vehicle_id: + return False + + record.parser_id = f"encar:{canonical_vehicle_id}" + record.origin_id = f"encar:{canonical_vehicle_id}" + record.origin_url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=canonical_vehicle_id) + record.slug = self.car_mapper._build_slug(record.brand, record.model, canonical_vehicle_id) + return True + + @staticmethod + def _merge_record_images(primary: CarRecord, candidate: CarRecord) -> bool: + merged: dict[str, ImageRecord] = {} + + def _put(images: list[ImageRecord]) -> None: + for image in images: + key = image.fullres_image + existing = merged.get(key) + if existing is None or image.order_index < existing.order_index: + merged[key] = image + + _put(primary.images) + _put(candidate.images) + + merged_images = sorted(merged.values(), key=lambda img: (img.order_index, img.fullres_image)) + if len(merged_images) != len(primary.images) or any( + left.fullres_image != right.fullres_image or left.order_index != right.order_index + for left, right in zip(primary.images, merged_images) + ): + primary.images = merged_images + return True + return False + + def collect_listing( + self, + limit: int | None = None, + page_size: int = DEFAULT_PAGE_SIZE, + filters: EncarFilters | None = None, + ) -> dict[str, Any]: + """Собирает список авто из API.""" + filters = filters or EncarFilters() + vehicle_items: list[dict] = [] + page = 0 + total = None + + logger.info("Collecting Encar listing with query: %s", filters.build_query()) + + while True: + offset = page * page_size + response = self._fetch_listing_page(offset=offset, limit=page_size, filters=filters) + results = response.get("SearchResults") or [] + + if total is None: + total = int(response.get("Count") or len(results)) + logger.info("Total available: %d", total) + + if not results: + break + + for item in results: + vehicle_id = str(item.get("Id") or "") + if not vehicle_id: + continue + vehicle_items.append(item) + if limit is not None and len(vehicle_items) >= limit: + break + + if limit is not None and len(vehicle_items) >= limit: + break + if len(results) < page_size: + break + + page += 1 + if page % 10 == 0: + logger.info("Collected %d items from %d pages...", len(vehicle_items), page) + + logger.info("Finished collecting: %d items from %d pages", len(vehicle_items), page + 1) + + return { + "items": vehicle_items, + "vehicle_urls": [ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=item["Id"]) for item in vehicle_items], + "pages_collected": page + 1, + "items_collected": len(vehicle_items), + "total_available": total, + } + + def scrape_vehicle_detail(self, vehicle_url: str) -> dict[str, Any]: + """Получает детальную информацию по одному авто.""" + vehicle_id = self._extract_vehicle_id(vehicle_url) + if not vehicle_id: + raise ValueError(f"Cannot extract Encar vehicle id from {vehicle_url}") + + # Сначала пробуем получить из листинга (там больше данных) + payload = self._fetch_listing_item(vehicle_id) + + # Если нет в листинге — берём из детального API + if payload is None: + payload = self._fetch_vehicle_detail_api(vehicle_id) + + if payload is None: + raise ValueError(f"Encar vehicle {vehicle_id} not found") + + record = self.car_mapper.map_to_car_record(vehicle_url, payload) + return { + "vehicle_id": vehicle_id, + "payload": payload, + "db_record": record.model_dump(mode="json"), + } + + def init_db(self) -> dict[str, Any]: + self.persistence.create_tables() + return {"status": "ok"} + + def sync_vehicle(self, vehicle_url: str, lane: str = "encar") -> dict[str, Any]: + """Синхронизирует одно авто в БД.""" + self.persistence.create_tables() + payload = self.scrape_vehicle_detail(vehicle_url) + record = CarRecord.model_validate(payload["db_record"]) + result = self.persistence.upsert_car(record) + return { + "status": "success", + "lane": lane, + "vehicle_url": vehicle_url, + "origin_id": record.origin_id, + **result, + } + + CHECKPOINT_KEY = "encar:sync:checkpoint" + CHECKPOINT_SAVE_INTERVAL = 10 # сохранять чекпоинт каждые N страниц + SHARD_MAX_ITEMS = 9500 # макс. элементов в одном шарде (API лимит ~10k) + + # ------------------------------------------------------------------ sharding + + def _get_listing_count(self, filters: EncarFilters) -> int: + """Получить Count для фильтра без выгрузки данных.""" + resp = self._fetch_listing_page(offset=0, limit=1, filters=filters) + return int(resp.get("Count") or 0) + + def _build_sync_shards(self, base_filters: EncarFilters) -> list[EncarFilters]: + """Авто-сегментация фильтров для обхода лимита пагинации API (~10k). + + Быстрый алгоритм (~15 API calls вместо ~90): + 1. total <= 9500 → 1 шард. + 2. Разбиваем по car_type (Y/N). + 3. Группируем старые годы декадами (2000-2009 → один шард). + 4. Только годы > 9500 разбиваем на полугодия. + + seen_vehicle_ids в sync_listing убирает дубли между шардами. + """ + MAX = self.SHARD_MAX_ITEMS + + total = self._get_listing_count(base_filters) + if total <= MAX: + logger.info("Total %d ≤ %d — single shard", total, MAX) + return [base_filters] + + logger.info("Total %d > %d — building shards", total, MAX) + + if base_filters.car_type in (CAR_TYPE_ALL, None, ""): + car_types = ["Y", "N"] + else: + car_types = [base_filters.car_type] + + shards: list[EncarFilters] = [] + shard_items = 0 + + for ct in car_types: + ct_f = replace(base_filters, car_type=ct) + ct_count = self._get_listing_count(ct_f) + if ct_count == 0: + continue + if ct_count <= MAX: + shards.append(ct_f) + shard_items += ct_count + continue + + # Сначала пробуем группировку: все годы до 2014 одним шардом + old_f = replace(ct_f, year_from=None, year_to=201412) + old_count = self._get_listing_count(old_f) + if old_count > 0: + if old_count <= MAX: + shards.append(old_f) + shard_items += old_count + else: + # Разбиваем на 2 части: до 2009 и 2010-2014 + for ys, ye in [(None, 200912), (201001, 201412)]: + part_f = replace(ct_f, year_from=ys, year_to=ye) + part_c = self._get_listing_count(part_f) + if part_c > 0: + shards.append(part_f) + shard_items += part_c + + # Годы 2015-2027: проверяем каждый + current_year = time.localtime().tm_year + for year in range(2015, current_year + 2): + yf = replace(ct_f, year_from=year, year_to=year) + yc = self._get_listing_count(yf) + if yc == 0: + continue + if yc <= MAX: + shards.append(yf) + shard_items += yc + else: + # Разбиваем на полугодия + for ms, me in [(year * 100 + 1, year * 100 + 6), + (year * 100 + 7, year * 100 + 12)]: + hf = replace(ct_f, year_from=ms, year_to=me) + hc = self._get_listing_count(hf) + if hc == 0: + continue + if hc <= MAX: + shards.append(hf) + shard_items += hc + else: + # Полугодие > MAX — разбиваем на кварталы + q_ranges = [(ms, ms + 2), (ms + 3, me)] if me - ms == 5 else [(ms, ms + 2), (ms + 3, me)] + for qs, qe in q_ranges: + qf = replace(ct_f, year_from=qs, year_to=qe) + qc = self._get_listing_count(qf) + if qc > 0: + if qc > MAX: + logger.warning("Quarter shard %s: %d items (> %d)", qf.build_query(), qc, MAX) + shards.append(qf) + shard_items += qc + + logger.info("Generated %d shards (≈ %d total items)", len(shards), shard_items) + return shards + + # ------------------------------------------------------------------ checkpoint + + def _load_checkpoint(self, redis_client: Any | None) -> dict[str, Any] | None: + """Загрузить чекпоинт из Redis.""" + if redis_client is None: + return None + try: + data = redis_client.get(self.CHECKPOINT_KEY) + if data: + cp = json.loads(data) + logger.info( + "Resuming from checkpoint: shard=%d, page=%d, synced=%d, collected=%d", + cp.get("shard_idx", 0), cp.get("page", 0), + cp.get("synced", 0), cp.get("items_collected", 0), + ) + return cp + except Exception as exc: + logger.warning("Failed to load checkpoint: %s", exc) + return None + + def _save_checkpoint(self, redis_client: Any | None, state: dict[str, Any]) -> None: + """Сохранить чекпоинт в Redis (TTL = 6 часов).""" + if redis_client is None: + return + try: + redis_client.set(self.CHECKPOINT_KEY, json.dumps(state), ex=21600) + except Exception as exc: + logger.warning("Failed to save checkpoint: %s", exc) + + def _clear_checkpoint(self, redis_client: Any | None) -> None: + """Удалить чекпоинт после успешного завершения.""" + if redis_client is None: + return + try: + redis_client.delete(self.CHECKPOINT_KEY) + except Exception: + pass + + def sync_listing( + self, + limit: int | None = None, + filters: EncarFilters | None = None, + lane: str = "encar", + only_new: bool = False, + batch_size: int = 1000, + redis_client: Any | None = None, + allowed_brands: set[str] | None = None, + excluded_brands: set[str] | None = None, + runtime_filters: FiltersConfig | None = None, + probe_all_photos: bool = False, + ) -> dict[str, Any]: + """Публичная обёртка: гарантирует закрытие HTTP pool даже при ошибках.""" + try: + return self._sync_listing_impl( + limit=limit, + filters=filters, + lane=lane, + only_new=only_new, + batch_size=batch_size, + redis_client=redis_client, + allowed_brands=allowed_brands, + excluded_brands=excluded_brands, + runtime_filters=runtime_filters, + probe_all_photos=probe_all_photos, + ) + finally: + if self._batch_pool is not None: + try: + self._batch_pool.close() + except Exception: + logger.debug("Failed to close batch pool", exc_info=True) + self._batch_pool = None + + def _sync_listing_impl( + self, + limit: int | None = None, + filters: EncarFilters | None = None, + lane: str = "encar", + only_new: bool = False, + batch_size: int = 1000, + redis_client: Any | None = None, + allowed_brands: set[str] | None = None, + excluded_brands: set[str] | None = None, + runtime_filters: FiltersConfig | None = None, + probe_all_photos: bool = False, + ) -> dict[str, Any]: + """Полная синхронизация листинга Encar. + + Encar API ограничивает пагинацию ~10k записей на один query. + Для получения всех 160k+ авто запрос автоматически разбивается + на шарды по car_type → year → quarter (через _build_sync_shards). + + 1. Генерирует шарды (фильтры), каждый ≤ 9500 записей. + 2. Для каждого шарда собирает все авто, upsert в БД. + 3. Помечает is_sold=True авто, которых нет в листинге. + + Чекпоинт через Redis: сохраняет shard_idx + page для продолжения + после рестарта. + """ + self.persistence.create_tables() + filters = filters or EncarFilters() + page_size = self.DEFAULT_PAGE_SIZE + compiled_runtime_filters = self._compile_runtime_filters(runtime_filters) + + # --- Генерация шардов --- + if limit: + # С limit не разбиваем на шарды + shards = [filters] + else: + shards = self._build_sync_shards(filters) + + # --- Загрузка чекпоинта --- + checkpoint = self._load_checkpoint(redis_client) + start_shard_idx = 0 + start_page = 0 + total_available = 0 + items_collected = 0 + synced = 0 + failed = 0 + failed_ids: list[str] = [] + all_origin_ids: set[str] = set() + + if checkpoint and not limit: + start_shard_idx = checkpoint.get("shard_idx", 0) + start_page = checkpoint.get("page", 0) + items_collected = checkpoint.get("items_collected", 0) + synced = checkpoint.get("synced", 0) + failed = checkpoint.get("failed", 0) + total_available = checkpoint.get("total_available", 0) + logger.info( + "Checkpoint loaded: shard=%d/%d, page=%d, synced=%d", + start_shard_idx, len(shards), start_page, synced, + ) + + logger.info( + "Full sync Encar: %d shards, base query: %s", + len(shards), filters.build_query(), + ) + + # --- Фаза 1: Сбор и upsert по шардам --- + seen_vehicle_ids: set[str] = set() # Глобальный дедуп между шардами + + for shard_idx, shard_filters in enumerate(shards): + if shard_idx < start_shard_idx: + continue + + page = start_page if shard_idx == start_shard_idx else 0 + start_page = 0 # сбрасываем после первого шарда + + batch: list[Any] = [] + consecutive_errors = 0 + max_consecutive_errors = 5 + consecutive_zero_new = 0 + max_consecutive_zero_new = 5 + # Защита от бесконечной пагинации: Encar API отдаёт максимум + # ~10k записей на query, шарды строятся ≤9500 → не более ~10 + # страниц при page_size=1000. Жёсткий cap в 50 — паранойя. + max_pages_per_shard = 50 + + shard_query = shard_filters.build_query() + shard_collected = 0 + + while True: + if page >= max_pages_per_shard: + logger.warning( + "Shard %d hit hard page cap (%d), moving to next", + shard_idx, max_pages_per_shard, + ) + break + offset = page * page_size + try: + response = self._fetch_listing_page( + offset=offset, limit=page_size, filters=shard_filters, + ) + except Exception as exc: + consecutive_errors += 1 + logger.warning( + "Shard %d page %d fetch error (%d/%d): %s", + shard_idx, page, consecutive_errors, max_consecutive_errors, exc, + ) + if consecutive_errors >= max_consecutive_errors: + logger.error("Too many errors in shard %d, moving to next", shard_idx) + break + time.sleep(5 * consecutive_errors) + continue + + consecutive_errors = 0 + results = response.get("SearchResults") or [] + + if page == 0: + shard_total = int(response.get("Count") or len(results)) + if shard_idx == 0 or total_available == 0: + total_available = self._get_listing_count(filters) + if len(shards) > 1: + logger.info( + "Shard %d/%d (%s): %d items", + shard_idx + 1, len(shards), shard_query, shard_total, + ) + + if not results: + break + + page += 1 + + new_in_page = 0 + for item in results: + vehicle_id = str(item.get("Id") or "") + if not vehicle_id: + continue + if vehicle_id in seen_vehicle_ids: + continue + seen_vehicle_ids.add(vehicle_id) + batch.append(item) + items_collected += 1 + shard_collected += 1 + new_in_page += 1 + + if limit is not None and items_collected >= limit: + break + + # API зацикливает после ~10k → выход из шарда + if new_in_page == 0: + consecutive_zero_new += 1 + if consecutive_zero_new >= max_consecutive_zero_new: + logger.info( + "Shard %d pagination exhausted at page=%d, collected=%d", + shard_idx, page, shard_collected, + ) + break + else: + consecutive_zero_new = 0 + + # Flush batch + if len(batch) >= batch_size: + s, f, fids = self._flush_batch( + batch, all_origin_ids, allowed_brands, + excluded_brands, + runtime_filters=compiled_runtime_filters, + probe_all_photos=probe_all_photos, + ) + synced += s + failed += f + failed_ids.extend(fids) + batch = [] + + logger.info( + "Progress: shard=%d/%d, page=%d, collected=%d, synced=%d, failed=%d", + shard_idx + 1, len(shards), page, items_collected, synced, failed, + ) + + # Чекпоинт + if page % self.CHECKPOINT_SAVE_INTERVAL == 0: + self._save_checkpoint(redis_client, { + "shard_idx": shard_idx, + "page": page, + "items_collected": items_collected, + "synced": synced, + "failed": failed, + "total_available": total_available, + }) + + if limit is not None and items_collected >= limit: + break + + if len(results) < page_size: + break + + if page % 10 == 0: + time.sleep(0.5) + + # Flush remaining batch после каждого шарда + if batch: + s, f, fids = self._flush_batch( + batch, all_origin_ids, allowed_brands, + excluded_brands, + runtime_filters=compiled_runtime_filters, + probe_all_photos=probe_all_photos, + ) + synced += s + failed += f + failed_ids.extend(fids) + batch = [] + + # Чекпоинт после завершения шарда + self._save_checkpoint(redis_client, { + "shard_idx": shard_idx + 1, + "page": 0, + "items_collected": items_collected, + "synced": synced, + "failed": failed, + "total_available": total_available, + }) + + if limit is not None and items_collected >= limit: + break + + # Пауза между шардами + if shard_idx < len(shards) - 1: + time.sleep(1) + + if items_collected == 0: + logger.warning("No items collected from listing") + return { + "status": "empty", + "lane": lane, + "total_available": total_available, + "items_collected": 0, + "cars_synced": 0, + "cars_failed": 0, + "cars_marked_sold": 0, + } + + # --- Фаза 2: Пометить sold --- + # Помечаем sold только если прошли ВСЕ шарды полностью (без resume из середины). + # Иначе all_origin_ids неполный и мы ошибочно пометим активные авто. + marked_sold = 0 + if not limit and checkpoint is None: + marked_sold = self.persistence.mark_sold_not_in_listing( + active_origin_ids=all_origin_ids, lane=lane, + ) + elif not limit: + logger.info( + "Skipping mark_sold: sync resumed from shard %d, origin_ids may be incomplete", + start_shard_idx, + ) + + self._clear_checkpoint(redis_client) + + logger.info( + "Full sync complete: %d shards, %d collected, %d synced, %d failed, %d marked sold", + len(shards), items_collected, synced, failed, marked_sold, + ) + + return { + "status": "success", + "lane": lane, + "total_available": total_available, + "items_collected": items_collected, + "cars_synced": synced, + "cars_failed": failed, + "cars_marked_sold": marked_sold, + "shards_count": len(shards), + "failed_ids": failed_ids[:20], + } + + def _flush_batch( + self, + items: list[Any], + all_origin_ids: set[str], + allowed_brands: set[str] | None = None, + excluded_brands: set[str] | None = None, + runtime_filters: RuntimeFilterSpec | None = None, + probe_all_photos: bool = False, + ) -> tuple[int, int, list[str]]: + """Маппит и upsert'ит батч items в БД. Возвращает (synced, failed, failed_ids).""" + records: list[CarRecord] = [] + # Для пакетного пробинга: (record_index, item) пар, прошедших фильтр + probe_tasks: list[tuple[int, Any]] = [] + failed = 0 + failed_ids: list[str] = [] + skipped_brands = 0 + skipped_runtime = 0 + canonicalized_records = 0 + + # Фаза 1: маппинг без пробинга + фильтр брендов + for item in items: + vehicle_id = str(item.get("Id") or "") + try: + vehicle_url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=vehicle_id) + record = self.car_mapper.map_to_car_record(vehicle_url, item, probe_all_photos=False) + + brand_lower = record.brand.lower() if record.brand else "" + if allowed_brands and brand_lower not in allowed_brands: + skipped_brands += 1 + continue + if excluded_brands and brand_lower in excluded_brands: + skipped_brands += 1 + continue + if not self._record_passes_runtime_filters(record, runtime_filters): + skipped_runtime += 1 + continue + + records.append(record) + probe_tasks.append((len(records) - 1, item)) + except Exception as exc: + logger.warning("Failed to map vehicle %s: %s", vehicle_id, exc) + failed += 1 + failed_ids.append(vehicle_id) + + # Фаза 2: фото — берём из photo API (все фото) или только из листинга (4 фото) + if records and probe_all_photos: + t0 = time.monotonic() + self._batch_fetch_photo_lists(records, probe_tasks) + probe_time = time.monotonic() - t0 + total_images_fetched = sum(len(r.images) for r in records) + logger.info( + "Photo API fetch: %d cars, %d total images in %.1fs", + len(records), total_images_fetched, probe_time, + ) + elif records: + # Без пробинга: берём только фото из API (как reference-проект) + self._apply_api_photos(records, probe_tasks) + + if records: + for record in records: + if self._apply_canonical_vehicle_id(record): + canonicalized_records += 1 + + deduped_records: list[CarRecord] = [] + deduped_by_origin_id: dict[str, CarRecord] = {} + collapsed_duplicates = 0 + merged_image_sets = 0 + for record in records: + existing = deduped_by_origin_id.get(record.origin_id) + if existing is not None: + collapsed_duplicates += 1 + if self._merge_record_images(existing, record): + merged_image_sets += 1 + continue + deduped_by_origin_id[record.origin_id] = record + deduped_records.append(record) + all_origin_ids.add(record.origin_id) + records = deduped_records + + if canonicalized_records or collapsed_duplicates or merged_image_sets: + logger.info( + "Canonicalized %d Encar records by photo vehicle id collapsed %d duplicates and merged %d image sets in batch", + canonicalized_records, + collapsed_duplicates, + merged_image_sets, + ) + + # Фаза 3: upsert в БД + if records: + try: + result = self.persistence.upsert_cars_batch(records) + synced = result.get("inserted", 0) + result.get("updated", 0) + except Exception as exc: + logger.error("Batch upsert failed: %s", exc) + synced = 0 + failed += len(records) + failed_ids.extend(r.origin_id for r in records) + else: + synced = 0 + + total_images = sum(len(r.images) for r in records) + logger.info( + "Batch stats: %d items → %d after runtime filter (-%d brands, -%d runtime), %d synced, %d total images", + len(items), len(records), skipped_brands, skipped_runtime, synced, total_images, + ) + + return synced, failed, failed_ids + + def _apply_api_photos( + self, + records: list[CarRecord], + probe_tasks: list[tuple[int, Any]], + ) -> None: + """Применяет фото из API (Photo + Photos) без HEAD-пробинга.""" + for rec_idx, item in probe_tasks: + photos = item.get("Photos") or [] + photo_prefix = self.car_mapper._as_str(item.get("Photo") or "") + + images: list[ImageRecord] = [] + for raw in (photos if isinstance(photos, list) else []): + if not isinstance(raw, dict): + continue + location = raw.get("location") or raw.get("Location") or "" + if not location: + continue + url = self.car_mapper._build_image_url(str(location)) + order = int(raw.get("ordering") or 0) + images.append(ImageRecord(fullres_image=url, preview_image=url, order_index=order)) + + # Если Photos пусто, но Photo есть — создаём хотя бы одну запись + if not images and photo_prefix: + url = self.car_mapper._build_image_url(photo_prefix.rstrip("_") + "_001.jpg") + images.append(ImageRecord(fullres_image=url, preview_image=url, order_index=1)) + + images.sort(key=lambda x: x.order_index) + records[rec_idx].images = images + + def _batch_fetch_photo_lists( + self, + records: list[CarRecord], + probe_tasks: list[tuple[int, Any]], + ) -> None: + """Получает полные списки фото через batch API (/v1/readside/vehicles). + + Batch endpoint найден в JS-бандле мобильной SPA (fem.encar.com). + Принимает до 20 vehicleIds за запрос, возвращает все фото каждой машины. + В ~4.4x быстрее legacy photo API (370 vs 61 машин/с) и без connection errors. + """ + tasks: list[tuple[int, str]] = [] + for rec_idx, item in probe_tasks: + vehicle_id = str(item.get("Id") or "") + if vehicle_id: + tasks.append((rec_idx, vehicle_id)) + + if not tasks: + return + + pool = self._get_batch_pool() + + # Разбиваем на чанки по BATCH_VEHICLES_CHUNK_SIZE (20) ID + chunks: list[list[tuple[int, str]]] = [] + for i in range(0, len(tasks), BATCH_VEHICLES_CHUNK_SIZE): + chunks.append(tasks[i:i + BATCH_VEHICLES_CHUNK_SIZE]) + + def _fetch_chunk(chunk: list[tuple[int, str]]) -> list[tuple[int, list[ImageRecord]]]: + """Запрашивает фото для чанка машин одним batch-запросом.""" + ids_str = ",".join(vid for _, vid in chunk) + path = f"/v1/readside/vehicles?vehicleIds={ids_str}&include=PHOTOS" + for attempt in range(2): + try: + resp = pool.request("GET", path, timeout=15, retries=False) + if resp.status == 200: + if not resp.data: + return [(ri, []) for ri, _ in chunk] + vehicles = json.loads(resp.data.decode("utf-8", errors="ignore")) + # Маппим по vehicleId, а не по индексу — API может пропускать удалённые ID + id_to_rec: dict[str, int] = {vid: ri for ri, vid in chunk} + result: list[tuple[int, list[ImageRecord]]] = [] + for vehicle_data in vehicles: + vid = str(vehicle_data.get("vehicleId") or vehicle_data.get("id") or "") + rec_idx = id_to_rec.get(vid) + if rec_idx is None: + continue + photos_raw = vehicle_data.get("photos") or [] + images: list[ImageRecord] = [] + seen_paths: set[str] = set() + for photo in photos_raw: + p = photo.get("path") or "" + if not p or p in seen_paths: + continue + seen_paths.add(p) + full_url = self.car_mapper._build_image_url(p) + code = photo.get("code") or "0" + order = int(code) if code.isdigit() else 0 + images.append(ImageRecord( + fullres_image=full_url, + preview_image=full_url, + order_index=order, + )) + images.sort(key=lambda x: x.order_index) + result.append((rec_idx, images)) + return result + if resp.status in (429, 503): + time.sleep(1 + attempt * 2) + continue + return [(ri, []) for ri, _ in chunk] + except json.JSONDecodeError: + return [(ri, []) for ri, _ in chunk] + except Exception as exc: + logger.debug("Batch photo chunk error (attempt %d): %s", attempt + 1, exc) + if attempt < 1: + time.sleep(1) + return [(ri, []) for ri, _ in chunk] + + results: dict[int, list[ImageRecord]] = {} + with ThreadPoolExecutor(max_workers=30) as executor: + futures = {executor.submit(_fetch_chunk, ch): ch for ch in chunks} + for future in as_completed(futures): + try: + for rec_idx, images in future.result(): + if images: + results[rec_idx] = images + except Exception as exc: + logger.warning("Batch photo future failed: %s", exc) + + # Применяем: batch API → fallback на Photos из листинга + api_ok = 0 + fallback_count = 0 + for rec_idx, item in probe_tasks: + if rec_idx in results: + records[rec_idx].images = results[rec_idx] + api_ok += 1 + else: + fallback_count += 1 + photos = item.get("Photos") or [] + photo_prefix = self.car_mapper._as_str(item.get("Photo") or "") + images: list[ImageRecord] = [] + for raw in (photos if isinstance(photos, list) else []): + if not isinstance(raw, dict): + continue + location = raw.get("location") or raw.get("Location") or "" + if not location: + continue + url = self.car_mapper._build_image_url(str(location)) + order = int(raw.get("ordering") or 0) + images.append(ImageRecord(fullres_image=url, preview_image=url, order_index=order)) + if not images and photo_prefix: + url = self.car_mapper._build_image_url(photo_prefix.rstrip("_") + "_001.jpg") + images.append(ImageRecord(fullres_image=url, preview_image=url, order_index=1)) + images.sort(key=lambda x: x.order_index) + records[rec_idx].images = images + if fallback_count: + logger.info("Batch photo API: %d OK, %d fallback to listing photos", api_ok, fallback_count) + + def _fetch_listing_page(self, offset: int, limit: int, filters: EncarFilters | None = None) -> dict[str, Any]: + filters = filters or EncarFilters() + query = filters.build_query() + params = { + "count": "true", + "q": query, + # CreatedDate — стабильная сортировка (дата создания объявления не меняется). + # ModifiedDate нестабильна: машины "всплывают" при обновлении цены/фото, + # вызывая до 50% дублей на поздних страницах длинного синка. + "sr": f"|CreatedDate|{offset}|{limit}", + } + url = f"{ENCAR_LISTING_API}?{urlencode(params)}" + return self._fetch_json(url) + + def _fetch_listing_item(self, vehicle_id: str) -> dict[str, Any] | None: + """Ищет авто в листинге по ID (макс 5 страниц).""" + page = 0 + page_size = 100 + max_pages = 5 # Ограничиваем поиск + + while page < max_pages: + response = self._fetch_listing_page(offset=page * page_size, limit=page_size) + results = response.get("SearchResults") or [] + if not results: + break + for item in results: + if str(item.get("Id")) == vehicle_id: + return item + if len(results) < page_size: + break + page += 1 + return None + + def _fetch_vehicle_detail_api(self, vehicle_id: str) -> dict[str, Any] | None: + url = ENCAR_DETAIL_API_TEMPLATE.format(vehicle_id=vehicle_id) + params = { + "include": ",".join(["ADVERTISEMENT", "VEHICLE", "SPECS", "IMAGE", "CONDITION"]) + } + full_url = f"{url}?{urlencode(params)}" + payload = self._fetch_json(full_url, headers={"Referer": ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id=vehicle_id)}) + return payload if payload and payload.get("vehicleId") else None + + def _fetch_json(self, url: str, headers: dict[str, str] | None = None) -> dict[str, Any]: + request_headers = { + "User-Agent": self.settings.fingerprint.user_agent, + "Accept": "application/json, text/plain, */*", + "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8", + "Referer": "https://www.encar.com/", + } + if headers: + request_headers.update(headers) + req = Request(url, headers=request_headers) + for attempt in range(3): + try: + with urlopen(req, timeout=30) as resp: + content = resp.read().decode("utf-8", errors="ignore") + return json.loads(content) + except (HTTPError, URLError, ConnectionError, TimeoutError, ValueError, OSError) as exc: + logger.warning("Encar JSON fetch failed (%s) for %s; attempt %d", exc, url, attempt + 1) + if attempt == 2: + raise + time.sleep(2 + attempt * 2) + return {} + + def _extract_vehicle_id(self, vehicle_url: str) -> str | None: + match = VEHICLE_ID_RE.search(vehicle_url) + if match: + return match.group(1) + return None diff --git a/encar_scraper/partner_dictionary.py b/encar_scraper/partner_dictionary.py new file mode 100644 index 0000000..6024399 --- /dev/null +++ b/encar_scraper/partner_dictionary.py @@ -0,0 +1,326 @@ +"""Korean -> English translation tables for EnCar (partner-provided basis). + +Источник переводов от партнёра. Этот файл — эталон: бренды и модели берём +отсюда в первую очередь. Держим секционные словари плюс один плоский для +простых поисков. +""" + +from __future__ import annotations + +ENCAR_KO_EN_TABLES: dict[str, dict[str, str]] = { + "makers": { + "포르쉐": "Porsche", + "메르세데스-벤츠": "Mercedes-Benz", + "메르세데스 벤츠": "Mercedes-Benz", + "벤츠": "Mercedes-Benz", + "메르세데스-AMG": "Mercedes-AMG", + "메르세데스 AMG": "Mercedes-AMG", + "AMG": "AMG", + "BMW": "BMW", + "아우디": "Audi", + "폭스바겐": "Volkswagen", + "렉서스": "Lexus", + "토요타": "Toyota", + "닛산": "Nissan", + "인피니티": "Infiniti", + "혼다": "Honda", + "마쯔다": "Mazda", + "스바루": "Subaru", + "미쓰비시": "Mitsubishi", + "랜드로버": "Land Rover", + "재규어": "Jaguar", + "지프": "Jeep", + "볼보": "Volvo", + "푸조": "Peugeot", + "르노": "Renault", + "미니": "MINI", + "로터스": "Lotus", + "벤틀리": "Bentley", + "롤스로이스": "Rolls-Royce", + "람보르기니": "Lamborghini", + "페라리": "Ferrari", + "맥라렌": "McLaren", + "애스턴마틴": "Aston Martin", + "애스톤마틴": "Aston Martin", + "마세라티": "Maserati", + "코닉세그": "Koenigsegg", + "파가니": "Pagani", + "부가티": "Bugatti", + "캐딜락": "Cadillac", + "쉐보레": "Chevrolet", + "포드": "Ford", + "링컨": "Lincoln", + "크라이슬러": "Chrysler", + "닷지": "Dodge", + "램": "Ram", + "테슬라": "Tesla", + "제네시스": "Genesis", + "현대": "Hyundai", + "기아": "Kia", + "KG모빌리티": "KG Mobility", + "쌍용": "SsangYong", + "알파로메오": "Alfa Romeo", + "피아트": "Fiat", + "시트로엥": "Citroen", + "스마트": "Smart", + "폴스타": "Polestar", + "리비안": "Rivian", + "루시드": "Lucid", + "BYD": "BYD", + "지리": "Geely", + "홍치": "Hongqi", + "이스즈": "Isuzu", + }, + "models": { + "718 박스터": "718 Boxster", + "718 카이맨": "718 Cayman", + "911": "911", + "911 카브리올레": "911 Cabriolet", + "카이엔": "Cayenne", + "카이엔 쿠페": "Cayenne Coupe", + "파나메라": "Panamera", + "파나메라 스포츠 투리스모": "Panamera Sport Turismo", + "마칸": "Macan", + "타이칸": "Taycan", + "타이칸 크로스 투리스모": "Taycan Cross Turismo", + "918 스파이더": "918 Spyder", + "카레라 GT": "Carrera GT", + "718 스파이더": "718 Spyder", + "박스터": "Boxster", + "카이맨": "Cayman", + "4.0 스파이더": "4.0 Spyder", + "C 클래스": "C-Class", + "E 클래스": "E-Class", + "S 클래스": "S-Class", + "A 클래스": "A-Class", + "B 클래스": "B-Class", + "CLA": "CLA", + "CLS": "CLS", + "SL": "SL", + "SLK": "SLK", + "G 클래스": "G-Class", + "GLA": "GLA", + "GLB": "GLB", + "GLC": "GLC", + "GLE": "GLE", + "GLS": "GLS", + "AMG GT": "AMG GT", + "AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe", + "V 클래스": "V-Class", + "1 시리즈": "1 Series", + "2 시리즈": "2 Series", + "3 시리즈": "3 Series", + "4 시리즈": "4 Series", + "5 시리즈": "5 Series", + "6 시리즈": "6 Series", + "7 시리즈": "7 Series", + "8 시리즈": "8 Series", + "M2": "M2", + "M3": "M3", + "M4": "M4", + "M5": "M5", + "M8": "M8", + "X1": "X1", + "X2": "X2", + "X3": "X3", + "X4": "X4", + "X5": "X5", + "X6": "X6", + "X7": "X7", + "Z4": "Z4", + "A3": "A3", + "A4": "A4", + "A5": "A5", + "A6": "A6", + "A7": "A7", + "A8": "A8", + "Q3": "Q3", + "Q5": "Q5", + "Q7": "Q7", + "Q8": "Q8", + "R8": "R8", + "TT": "TT", + "고스트": "Ghost", + "팬텀": "Phantom", + "레이스": "Wraith", + "던": "Dawn", + "컬리넌": "Cullinan", + "스펙터": "Spectre", + "벤테이가": "Bentayga", + "컨티넨탈 GT": "Continental GT", + "플라잉스퍼": "Flying Spur", + "우라칸": "Huracan", + "아벤타도르": "Aventador", + "우루스": "Urus", + "레부엘토": "Revuelto", + "로마": "Roma", + "포르토피노": "Portofino", + "SF90 스트라달레": "SF90 Stradale", + "F8 트리뷰토": "F8 Tributo", + "콰트로포르테": "Quattroporte", + "기블리": "Ghibli", + "르반떼": "Levante", + "그란투리스모": "GranTurismo", + "반퀴시": "Vanquish", + "밴티지": "Vantage", + "DB11": "DB11", + "DB12": "DB12", + "DBS": "DBS", + }, + "body_types": { + "세단": "Sedan", + "쿠페": "Coupe", + "컨버터블": "Convertible", + "카브리올레": "Cabriolet", + "오픈카": "Open Top", + "해치백": "Hatchback", + "왜건": "Wagon", + "스테이션왜건": "Station Wagon", + "SUV": "SUV", + "크로스오버": "Crossover", + "픽업트럭": "Pickup Truck", + "밴": "Van", + "미니밴": "Minivan", + "트럭": "Truck", + "쿠페형 SUV": "Coupe SUV", + "기타": "Other", + }, + "fuel_types": { + "가솔린": "Gasoline", + "디젤": "Diesel", + "경유": "Diesel", + "하이브리드": "Hybrid", + "가솔린 하이브리드": "Gasoline Hybrid", + "플러그인 하이브리드": "Plug-in Hybrid", + "전기": "Electric", + "전기차": "Electric", + "수소": "Hydrogen", + "LPG": "LPG", + "LNG": "LNG", + "가솔린+전기": "Gasoline + Electric", + "가솔린 + 전기": "Gasoline + Electric", + }, + "transmissions": { + "수동": "Manual", + "오토": "Automatic", + "자동": "Automatic", + "자동변속": "Automatic", + "세미오토": "Semi-automatic", + "CVT": "CVT", + "듀얼클러치": "Dual-clutch", + }, + "sell_types": { + "일반": "General", + "리스": "Lease", + "렌트": "Rent", + "할부": "Installment", + }, + "lease_types": { + "운용리스": "Operating Lease", + "금융리스": "Financial Lease", + }, + "regions": { + "서울": "Seoul", + "부산": "Busan", + "인천": "Incheon", + "대구": "Daegu", + "대전": "Daejeon", + "광주": "Gwangju", + "울산": "Ulsan", + "세종": "Sejong", + "경기": "Gyeonggi", + "강원": "Gangwon", + "충북": "Chungbuk", + "충남": "Chungnam", + "전북": "Jeonbuk", + "전남": "Jeonnam", + "경북": "Gyeongbuk", + "경남": "Gyeongnam", + "제주": "Jeju", + }, + "dealer_companies": { + "(주)기억": "Gieok Co., Ltd.", + "(주)네바퀴": "Four Wheels Co., Ltd.", + "(주)에이스타모터스": "A-Star Motors Co., Ltd.", + "(주)우진 오토모빌": "Woojin Automobile Co., Ltd.", + "(주)지온컴퍼니": "Zion Company Co., Ltd.", + "(주)플로시모터스": "Flossy Motors Co., Ltd.", + "㈜오토핸즈": "Auto Hands Co., Ltd.", + "GD카": "GD Car", + "MK모터스": "MK Motors", + "꿈차": "Dream Car", + "마이카4": "My Car 4", + "블루오토": "Blue Auto", + "세영모빌리티(주)": "Seyoung Mobility Co., Ltd.", + "스마일자동차": "Smile Motors", + "오토링크": "Auto Link", + "오토핸즈": "Auto Hands", + "우리들모터스": "Woorideul Motors", + "주식회사 모터바겐": "Motorwagen Co., Ltd.", + "주식회사 아이오토스": "iAutos Co., Ltd.", + "주식회사이들컴퍼니": "Ideul Company Co., Ltd.", + "카메이트": "Car Mate", + "카톡모터스": "Katok Motors", + "카피디": "Car PD", + "포르쉐 인증 중고차 센터 성수": "Porsche Certified Used Car Center Seongsu", + "포르쉐인증중고차센터성수": "Porsche Certified Used Car Center Seongsu", + }, + "dealer_names": { + "고준용": "Go Jun-yong", + "공민중": "Gong Min-jung", + "김경진": "Kim Gyeong-jin", + "김명진": "Kim Myeong-jin", + "김민수": "Kim Min-su", + "김성언": "Kim Seong-eon", + "김성진": "Kim Seong-jin", + "김영균": "Kim Yeong-gyun", + "김완섭": "Kim Wan-seop", + "김현진": "Kim Hyeon-jin", + "김형진": "Kim Hyeong-jin", + "김호영": "Kim Ho-yeong", + "문명기": "Moon Myeong-gi", + "박원석": "Park Won-seok", + "박재현": "Park Jae-hyeon", + "방지윤": "Bang Ji-yun", + "송슬기": "Song Seul-gi", + "신영철": "Shin Yeong-cheol", + "신호진": "Shin Ho-jin", + "양시언": "Yang Si-eon", + "양태규": "Yang Tae-gyu", + "윤지완": "Yoon Ji-wan", + "이경민": "Lee Gyeong-min", + "이재욱": "Lee Jae-uk", + "임종현": "Lim Jong-hyeon", + "임형욱": "Lim Hyeong-uk", + "전태준": "Jeon Tae-jun", + "정상필": "Jeong Sang-pil", + "조경식": "Jo Gyeong-sik", + "조승재": "Jo Seung-jae", + "조환성": "Jo Hwan-seong", + "차상훈": "Cha Sang-hun", + "최경욱": "Choi Gyeong-uk", + "하상열": "Ha Sang-yeol", + "허지훈": "Heo Ji-hun", + }, +} + + +def _build_flat_map() -> dict[str, str]: + result: dict[str, str] = {} + for section in ENCAR_KO_EN_TABLES.values(): + result.update(section) + return result + + +ENCAR_KO_EN_TRANSLATIONS: dict[str, str] = _build_flat_map() + + +def translate_encar_ko_to_en(value: str, section: str | None = None) -> str: + """Перевод корейского термина Encar на английский. + + Если задан `section`, используется только словарь этой секции. Иначе — + плоский объединённый словарь. + """ + if section is not None: + return ENCAR_KO_EN_TABLES.get(section, {}).get(value, value) + return ENCAR_KO_EN_TRANSLATIONS.get(value, value) diff --git a/encar_scraper/storage/__init__.py b/encar_scraper/storage/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/encar_scraper/storage/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/encar_scraper/storage/db.py b/encar_scraper/storage/db.py new file mode 100644 index 0000000..91f9568 --- /dev/null +++ b/encar_scraper/storage/db.py @@ -0,0 +1,497 @@ +import logging +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any, Iterator + +from sqlalchemy import create_engine, delete, or_, select, text, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session, sessionmaker + +from ..core.config import Settings +from .models import Base, Car, Image, SyncRun +from .schemas import CarRecord + +logger = logging.getLogger("encar_scraper.db") + + +CAR_DB_FIELDS = { + col.key for col in Car.__table__.columns + if col.key not in ("id",) +} + +_IN_CHUNK_SIZE = 5000 + + +class PersistenceService: + + def __init__(self, settings: Settings) -> None: + self.settings = settings + engine_kwargs = { + "echo": settings.database.echo, + "future": True, + } + if "postgresql" in settings.database.url: + engine_kwargs["pool_size"] = settings.database.pool_size + engine_kwargs["max_overflow"] = settings.database.max_overflow + engine_kwargs["pool_pre_ping"] = True + engine_kwargs["pool_recycle"] = settings.database.pool_recycle_seconds + self.engine = create_engine(settings.database.url, **engine_kwargs) + self.session_factory = sessionmaker(bind=self.engine, expire_on_commit=False, future=True) + + def create_tables(self) -> None: + # В тестах/локально на SQLite разрешаем create_all; для non-SQLite в проде — только через миграции. + is_sqlite = self.settings.database.url.startswith("sqlite") + if not is_sqlite and not self.settings.database.auto_create_tables: + return + try: + Base.metadata.create_all(self.engine) + except Exception: + # Не глотаем: таблицы могут уже существовать (ок), либо есть проблема + # доступа — пусть вышестоящий код видит её при первой операции, но + # сигнализируем в warning чтобы упростить диагностику. + logger.warning("create_tables failed (continuing — schema may already exist)", exc_info=True) + + @contextmanager + def session_scope(self) -> Iterator[Session]: + session = self.session_factory() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + def start_sync_run(self, lane: str) -> int: + with self.session_scope() as session: + now = datetime.now(timezone.utc) + stale_runs = session.execute(select(SyncRun).where(SyncRun.status == "running")).scalars().all() + for stale in stale_runs: + stale.status = "failed" + stale.finished_at = now + if not stale.error_summary: + stale.error_summary = "Recovered stale running sync run before starting a new run" + + run = SyncRun(status="running", lane=lane, ids_fetched=0, cars_upserted=0, cars_failed=0, images_upserted=0) + session.add(run) + session.flush() + return int(run.id) + + def finish_sync_run(self, run_id: int, *, status: str, ids_fetched: int, cars_upserted: int, cars_failed: int, images_upserted: int, error_summary: str | None = None) -> None: + with self.session_scope() as session: + run = session.get(SyncRun, run_id) + if run is None: + return + run.finished_at = datetime.now(timezone.utc) + run.status = status + run.ids_fetched = ids_fetched + run.cars_upserted = cars_upserted + run.cars_failed = cars_failed + run.images_upserted = images_upserted + run.error_summary = error_summary + + @staticmethod + def _add_images(session: Session, car_id: int, images: list[dict[str, object]]) -> None: + if not images: + return + session.add_all([ + Image( + fullres_image=str(img["fullres_image"]), + preview_image=str(img["preview_image"]), + order_index=int(img.get("order_index", 0)), + car_id=car_id, + ) + for img in images + ]) + + @staticmethod + def _car_payload(record: CarRecord) -> dict[str, object]: + payload = record.model_dump(mode="python") + return {key: value for key, value in payload.items() if key in CAR_DB_FIELDS} + + def _is_postgres(self) -> bool: + return self.engine.dialect.name == "postgresql" + + def _load_existing_cars( + self, + session: Session, + origin_ids: list[str], + origin_urls: list[str], + ) -> tuple[dict[str, Car], dict[str, Car]]: + existing_by_id: dict[str, Car] = {} + existing_by_url: dict[str, Car] = {} + if not origin_ids and not origin_urls: + return existing_by_id, existing_by_url + + existing_cars: list[Car] = [] + max_len = max(len(origin_ids), len(origin_urls), 1) + for i in range(0, max_len, _IN_CHUNK_SIZE): + id_chunk = origin_ids[i:i + _IN_CHUNK_SIZE] + url_chunk = origin_urls[i:i + _IN_CHUNK_SIZE] + conditions = [] + if id_chunk: + conditions.append(Car.origin_id.in_(id_chunk)) + if url_chunk: + conditions.append(Car.origin_url.in_(url_chunk)) + if not conditions: + continue + rows = session.execute( + select(Car).where(or_(*conditions)) + ).scalars().all() + existing_cars.extend(rows) + + for car in existing_cars: + if car.origin_id: + existing_by_id[car.origin_id] = car + if car.origin_url: + existing_by_url[car.origin_url] = car + return existing_by_id, existing_by_url + + def _load_existing_image_urls(self, session: Session, car_ids: set[int]) -> dict[int, set[str]]: + existing_images_map: dict[int, set[str]] = {} + if not car_ids: + return existing_images_map + + car_id_list = list(car_ids) + for i in range(0, len(car_id_list), _IN_CHUNK_SIZE): + chunk = car_id_list[i:i + _IN_CHUNK_SIZE] + img_rows = session.execute( + select(Image.car_id, Image.fullres_image).where(Image.car_id.in_(chunk)) + ).all() + for cid, furl in img_rows: + existing_images_map.setdefault(int(cid), set()).add(str(furl)) + return existing_images_map + + @staticmethod + def _postgres_upsert_set_map(insert_stmt) -> dict[str, object]: + return { + key: getattr(insert_stmt.excluded, key) + for key in CAR_DB_FIELDS + } + + def _replace_images_for_car( + self, + session: Session, + car_id: int, + images: list[dict[str, object]], + origin_id: str, + ) -> int: + nested = session.begin_nested() + try: + session.execute(delete(Image).where(Image.car_id == car_id)) + self._add_images(session, car_id, images) + session.flush() + nested.commit() + return len(images) + except Exception: + nested.rollback() + logger.warning("Image replacement failed for car %s, keeping old images", origin_id, exc_info=True) + return 0 + + def _upsert_cars_batch_postgres(self, records: list[CarRecord]) -> dict[str, int]: + inserted = 0 + updated = 0 + images_total = 0 + + with self.session_scope() as session: + origin_ids = [r.origin_id for r in records if r.origin_id] + origin_urls = [r.origin_url for r in records if r.origin_url] + existing_by_id, existing_by_url = self._load_existing_cars(session, origin_ids, origin_urls) + + entries: list[dict[str, object]] = [] + upsert_payloads: list[dict[str, object]] = [] + for record in records: + payload = self._car_payload(record) + images = [image.model_dump(mode="python") for image in record.images] + car_by_id = existing_by_id.get(record.origin_id) + car_by_url = existing_by_url.get(record.origin_url) + entry: dict[str, object] = {"record": record, "images": images, "car_id": None} + + if car_by_url is not None and car_by_url.origin_id != record.origin_id and car_by_id is None: + for key, value in payload.items(): + setattr(car_by_url, key, value) + car_by_url.last_seen_at = record.last_seen_at + entry["car_id"] = int(car_by_url.id) + updated += 1 + else: + upsert_payloads.append(payload) + if car_by_id is not None: + updated += 1 + else: + inserted += 1 + entries.append(entry) + + session.flush() + + if upsert_payloads: + insert_stmt = pg_insert(Car).values(upsert_payloads) + upsert_stmt = insert_stmt.on_conflict_do_update( + index_elements=[Car.origin_id], + set_=self._postgres_upsert_set_map(insert_stmt), + ).returning(Car.id, Car.origin_id) + rows = session.execute(upsert_stmt).all() + car_ids_by_origin_id = {str(origin_id): int(car_id) for car_id, origin_id in rows} + for entry in entries: + if entry["car_id"] is not None: + continue + record = entry["record"] + car_id = car_ids_by_origin_id.get(record.origin_id) + if car_id is None: + raise RuntimeError(f"PostgreSQL upsert did not return car_id for {record.origin_id}") + entry["car_id"] = car_id + + car_ids = {int(entry["car_id"]) for entry in entries if entry["car_id"] is not None} + existing_images_map = self._load_existing_image_urls(session, car_ids) + images_by_car_id: dict[int, list[dict[str, object]]] = {} + replace_ids: list[int] = [] + + for entry in entries: + car_id = int(entry["car_id"]) + images = entry["images"] + new_image_urls = { + str(img.get("fullres_image", "")) + for img in images + if img.get("fullres_image") + } + old_image_urls = existing_images_map.get(car_id, set()) + if new_image_urls != old_image_urls: + replace_ids.append(car_id) + images_by_car_id[car_id] = images + else: + images_total += len(old_image_urls) + + if replace_ids: + for i in range(0, len(replace_ids), _IN_CHUNK_SIZE): + chunk = replace_ids[i:i + _IN_CHUNK_SIZE] + session.execute(delete(Image).where(Image.car_id.in_(chunk))) + for car_id in replace_ids: + images = images_by_car_id[car_id] + self._add_images(session, car_id, images) + images_total += len(images) + + return {"inserted": inserted, "updated": updated, "images_upserted": images_total} + + def upsert_car(self, record: CarRecord): + # Вставка или обновление автомобиля по origin_id/origin_url. + payload = self._car_payload(record) + images = [image.model_dump(mode="python") for image in record.images] + with self.session_scope() as session: + if self._is_postgres(): + car_by_url = session.execute( + select(Car).where(Car.origin_url == record.origin_url) + ).scalar_one_or_none() + if car_by_url is not None and car_by_url.origin_id != record.origin_id: + for key, value in payload.items(): + setattr(car_by_url, key, value) + car_by_url.last_seen_at = record.last_seen_at + session.flush() + car_id = int(car_by_url.id) + action = "updated" + else: + existed = session.execute( + select(Car.id).where(Car.origin_id == record.origin_id) + ).scalar_one_or_none() is not None + insert_stmt = pg_insert(Car).values(**payload) + upsert_stmt = insert_stmt.on_conflict_do_update( + index_elements=[Car.origin_id], + set_=self._postgres_upsert_set_map(insert_stmt), + ).returning(Car.id) + car_id = int(session.execute(upsert_stmt).scalar_one()) + action = "updated" if existed or car_by_url is not None else "inserted" + + images_upserted = self._replace_images_for_car( + session, car_id, images, record.origin_id, + ) + return {"car_id": car_id, "images_upserted": images_upserted, "action": action} + + car = session.execute( + select(Car).where(or_(Car.origin_id == record.origin_id, Car.origin_url == record.origin_url)) + ).scalar_one_or_none() + action = "inserted" + if car is None: + car = Car(**payload) + session.add(car) + session.flush() + else: + action = "updated" + for key, value in payload.items(): + setattr(car, key, value) + car.last_seen_at = record.last_seen_at + session.flush() + images_upserted = self._replace_images_for_car(session, int(car.id), images, record.origin_id) + return {"car_id": int(car.id), "images_upserted": images_upserted, "action": action} + self._add_images(session, int(car.id), images) + session.flush() + return {"car_id": int(car.id), "images_upserted": len(images), "action": action} + def upsert_cars_batch(self, records: list[CarRecord]) -> dict[str, int]: + """Пакетный upsert нескольких автомобилей в одной транзакции. + + Оптимизации: + - Дедупликация записей по origin_id перед вставкой. + - Chunked IN-queries для больших списков (обход лимита PG параметров). + - Пропуск перезаписи изображений, если набор URL не изменился. + - Один DELETE по car_id IN (...) вместо удаления по одному. + - Fallback на по-одному upsert если batch commit упал. + """ + # ── Дедупликация записей внутри батча ── + seen_ids: dict[str, int] = {} + unique_records: list[CarRecord] = [] + for idx, r in enumerate(records): + key = r.origin_id or r.origin_url + if key in seen_ids: + logger.debug("Dedup: skipping duplicate record %s (idx %d vs %d)", key, idx, seen_ids[key]) + continue + seen_ids[key] = idx + unique_records.append(r) + + if len(unique_records) < len(records): + logger.info("Deduped batch: %d → %d records", len(records), len(unique_records)) + records = unique_records + + try: + return self._upsert_cars_batch_inner(records) + except Exception as exc: + logger.warning("Batch upsert failed (%s), falling back to individual upserts", exc) + return self._upsert_cars_individually(records) + + def _upsert_cars_batch_inner(self, records: list[CarRecord]) -> dict[str, int]: + """Внутренняя реализация batched upsert (одна транзакция).""" + if self._is_postgres(): + return self._upsert_cars_batch_postgres(records) + + inserted = 0 + updated = 0 + images_total = 0 + + with self.session_scope() as session: + # Получаем существующие записи chunked-запросами. + origin_ids = [r.origin_id for r in records if r.origin_id] + origin_urls = [r.origin_url for r in records if r.origin_url] + + existing_by_id, existing_by_url = self._load_existing_cars(session, origin_ids, origin_urls) + + # Предзагружаем ВСЕ изображения для обновляемых машин одним запросом. + existing_car_ids = set() + for record in records: + car = existing_by_id.get(record.origin_id) or existing_by_url.get(record.origin_url) + if car is not None: + existing_car_ids.add(int(car.id)) + + # Строим маппинг car_id → set(image_urls) для сравнения. + existing_images_map = self._load_existing_image_urls(session, existing_car_ids) + + new_cars: list[tuple[Car, list[dict]]] = [] + update_cars_needing_images: list[tuple[Car, list[dict]]] = [] + + for record in records: + payload = self._car_payload(record) + images = [image.model_dump(mode="python") for image in record.images] + + car = existing_by_id.get(record.origin_id) or existing_by_url.get(record.origin_url) + if car is None: + car = Car(**payload) + session.add(car) + inserted += 1 + new_cars.append((car, images)) + else: + for key, value in payload.items(): + setattr(car, key, value) + car.last_seen_at = record.last_seen_at + updated += 1 + + # Проверяем, изменились ли изображения. + new_image_urls = {img.get("fullres_image", "") for img in images} + old_image_urls = existing_images_map.get(int(car.id), set()) + if new_image_urls != old_image_urls: + update_cars_needing_images.append((car, images)) + else: + images_total += len(old_image_urls) + + # Один flush для всех вставок. + session.flush() + + # Добавляем изображения для новых автомобилей. + for car, images in new_cars: + self._add_images(session, int(car.id), images) + images_total += len(images) + + # Массово обновляем изображения только для машин с изменёнными картинками. + if update_cars_needing_images: + update_ids = [int(car.id) for car, _ in update_cars_needing_images] + for i in range(0, len(update_ids), _IN_CHUNK_SIZE): + chunk = update_ids[i:i + _IN_CHUNK_SIZE] + session.execute(delete(Image).where(Image.car_id.in_(chunk))) + for car, images in update_cars_needing_images: + self._add_images(session, int(car.id), images) + images_total += len(images) + + return {"inserted": inserted, "updated": updated, "images_upserted": images_total} + + def _upsert_cars_individually(self, records: list[CarRecord]) -> dict[str, int]: + # Fallback на поштучный upsert. + inserted = 0 + updated = 0 + images_total = 0 + for record in records: + try: + result = self.upsert_car(record) + action = result.get("action", "inserted") + if action == "inserted": + inserted += 1 + else: + updated += 1 + images_total += int(result.get("images_upserted", 0)) + except Exception as exc: + logger.error("Individual upsert failed for %s: %s", record.origin_id, exc) + return {"inserted": inserted, "updated": updated, "images_upserted": images_total} + + def mark_sold_not_in_listing(self, active_origin_ids: set[str], lane: str = "encar") -> int: + """Помечает авто как проданные, если их нет в активном листинге (по origin_id). + + Для PostgreSQL использует временную таблицу + LEFT JOIN вместо NOT IN, + что кардинально быстрее при больших объёмах (200K+ IDs). + """ + if not active_origin_ids: + return 0 + with self.session_scope() as session: + if self._is_postgres(): + session.execute(text("CREATE TEMP TABLE IF NOT EXISTS _active_ids (origin_id TEXT NOT NULL) ON COMMIT DROP")) + session.execute(text("TRUNCATE _active_ids")) + + id_list = list(active_origin_ids) + for i in range(0, len(id_list), _IN_CHUNK_SIZE): + chunk = id_list[i:i + _IN_CHUNK_SIZE] + values = ",".join(f"(:{f'u{j}'})" for j in range(len(chunk))) + params = {f"u{j}": oid for j, oid in enumerate(chunk)} + session.execute(text(f"INSERT INTO _active_ids (origin_id) VALUES {values}"), params) + + session.execute(text("CREATE INDEX IF NOT EXISTS _ix_active_ids ON _active_ids (origin_id)")) + + result = session.execute(text(""" + UPDATE cars + SET is_sold = TRUE + FROM ( + SELECT c.id + FROM cars c + LEFT JOIN _active_ids a ON c.origin_id = a.origin_id + WHERE a.origin_id IS NULL + AND c.is_sold = FALSE + AND c.origin_id LIKE 'encar:%%' + ) sub + WHERE cars.id = sub.id + """)) + count = result.rowcount or 0 + else: + stmt = ( + update(Car) + .where(Car.origin_id.notin_(active_origin_ids)) + .where(Car.is_sold == False) # noqa: E712 + .where(Car.origin_id.like("encar:%")) + .values(is_sold=True) + ) + result = session.execute(stmt) + count = result.rowcount or 0 + if count: + logger.info("Marked %d cars as sold (no longer in listing)", count) + return count \ No newline at end of file diff --git a/encar_scraper/storage/enums.py b/encar_scraper/storage/enums.py new file mode 100644 index 0000000..2b8ee39 --- /dev/null +++ b/encar_scraper/storage/enums.py @@ -0,0 +1,36 @@ +CURRENCY_ENUM_VALUES = ("JPY", "USD", "EUR", "RUB", "KRW", "AED", "GBP", "CAD") +DRIVE_ENUM_VALUES = ("FWD", "RWD", "2WD", "4WD", "NA") +GEARBOX_ENUM_VALUES = ("AT", "CVT", "MT", "EV", "NA") +STEERING_WHEEL_ENUM_VALUES = ("LEFT", "RIGHT", "NA") +BODY_TYPE_ENUM_VALUES = ( + "COUPE", + "SUV", + "HATCHBACK", + "MINIVAN", + "SEDAN", + "STATION_WAGON", + "PICKUP", + "TRUCK", + "OPEN", + "RV", + "VAN", + "CONVERTIBLE", + "BUS", + "COMPACT", + "MID_SIZE", + "FULL_SIZE", + "SPORTS", + "CLASSIC", + "ELECTRIC", + "HYBRID", + "SPECIAL", + "WAGON", + "OTHER", + "NA", +) +COUNTRY_ENUM_VALUES = ("JP", "KR", "US", "CA", "NA") +ORIGIN_ENUM_VALUES = ( + "ENCAR", + "NA", +) +SELLING_TYPE_ENUM_VALUES = ("STOCK", "AUCTION", "TENDER", "NA") diff --git a/encar_scraper/storage/models.py b/encar_scraper/storage/models.py new file mode 100644 index 0000000..1242e4b --- /dev/null +++ b/encar_scraper/storage/models.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from .enums import ( + BODY_TYPE_ENUM_VALUES, + COUNTRY_ENUM_VALUES, + CURRENCY_ENUM_VALUES, + DRIVE_ENUM_VALUES, + GEARBOX_ENUM_VALUES, + ORIGIN_ENUM_VALUES, + SELLING_TYPE_ENUM_VALUES, + STEERING_WHEEL_ENUM_VALUES, +) + + +class Base(DeclarativeBase): + pass + + +class Car(Base): + __tablename__ = "cars" + __table_args__ = ( + Index("ix_cars_brand_model", "brand", "model"), + Index("ix_cars_origin_id_not_sold", "origin_id", "is_sold"), + ) + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + parser_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + brand: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + model: Mapped[str] = mapped_column(String(500), nullable=False) + year: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + price: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + currency: Mapped[str] = mapped_column(Enum(*CURRENCY_ENUM_VALUES, name="currencyenum", native_enum=False, create_constraint=False), nullable=False, default="USD") + mileage: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + country: Mapped[str] = mapped_column(Enum(*COUNTRY_ENUM_VALUES, name="countryenum", native_enum=False, create_constraint=False), nullable=False, default="NA") + is_sold: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + color: Mapped[str] = mapped_column(String(), nullable=False, default="other") + drive: Mapped[str | None] = mapped_column(Enum(*DRIVE_ENUM_VALUES, name="driveenum", native_enum=False, create_constraint=False), nullable=True) + gearbox: Mapped[str | None] = mapped_column(Enum(*GEARBOX_ENUM_VALUES, name="gearboxenum", native_enum=False, create_constraint=False), nullable=True) + steering_wheel: Mapped[str | None] = mapped_column(Enum(*STEERING_WHEEL_ENUM_VALUES, name="steeringwheelenum", native_enum=False, create_constraint=False), nullable=True) + body_type: Mapped[str] = mapped_column(Enum(*BODY_TYPE_ENUM_VALUES, name="bodytypeenum", native_enum=False, create_constraint=False), nullable=False, default="OTHER") + engine_volume: Mapped[int | None] = mapped_column(Integer, nullable=True) + selling_type: Mapped[str] = mapped_column(Enum(*SELLING_TYPE_ENUM_VALUES, name="sellingtypeenum", native_enum=False, create_constraint=False), nullable=False, default="NA") + one_owner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + new_car: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_hidden: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + origin: Mapped[str] = mapped_column(Enum(*ORIGIN_ENUM_VALUES, name="originenum", native_enum=False, create_constraint=False), nullable=False, default="NA") + origin_url: Mapped[str] = mapped_column(String(), nullable=False, index=True) + origin_id: Mapped[str] = mapped_column(String(), nullable=False, unique=True, index=True) + is_damaged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + evaluation: Mapped[str | None] = mapped_column(String(), nullable=True) + non_smoking: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + rental: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + repair_history: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + slug: Mapped[str] = mapped_column(String(), nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True) + images: Mapped[list["Image"]] = relationship("Image", back_populates="car", cascade="all, delete-orphan") + + +class Image(Base): + __tablename__ = "images" + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + fullres_image: Mapped[str] = mapped_column(String(), nullable=False) + preview_image: Mapped[str] = mapped_column(String(), nullable=False) + order_index: Mapped[int] = mapped_column(Integer, nullable=False) + car_id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, index=True) + car: Mapped[Car] = relationship("Car", back_populates="images") + + +class SyncRun(Base): + __tablename__ = "sync_runs" + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + status: Mapped[str] = mapped_column(Text, nullable=False, index=True) + lane: Mapped[str] = mapped_column(Text, nullable=False) + ids_fetched: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cars_upserted: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cars_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + images_upserted: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error_summary: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/encar_scraper/storage/schemas.py b/encar_scraper/storage/schemas.py new file mode 100644 index 0000000..7af7c43 --- /dev/null +++ b/encar_scraper/storage/schemas.py @@ -0,0 +1,87 @@ +from datetime import datetime, timezone +from pydantic import BaseModel, ConfigDict, Field + + +class ImageRecord(BaseModel): + fullres_image: str + preview_image: str + order_index: int = 0 + + +class CarRecord(BaseModel): + parser_id: str + brand: str + model: str + year: int | None = None + price: int | None = None + currency: str = "USD" + mileage: int = 0 + country: str = "US" + is_sold: bool = False + color: str = "other" + drive: str | None = None + gearbox: str | None = None + steering_wheel: str | None = None + body_type: str = "OTHER" + engine_volume: int | None = None + selling_type: str = "AUCTION" + one_owner: bool = False + new_car: bool = False + is_hidden: bool = False + origin: str = "NA" + origin_url: str + origin_id: str + is_damaged: bool = False + evaluation: str | None = None + non_smoking: bool = True + rental: bool = False + repair_history: bool = False + slug: str + last_seen_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + images: list[ImageRecord] = Field(default_factory=list) + + +class ImageRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + fullres_image: str + preview_image: str + order_index: int = 0 + + +class CarRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + parser_id: str + brand: str + model: str + year: int | None = None + price: int | None = None + currency: str = "USD" + mileage: int = 0 + country: str = "US" + is_sold: bool = False + color: str = "other" + drive: str | None = None + gearbox: str | None = None + steering_wheel: str | None = None + body_type: str = "OTHER" + engine_volume: int | None = None + selling_type: str = "AUCTION" + one_owner: bool = False + new_car: bool = False + is_hidden: bool = False + origin: str = "NA" + origin_url: str = "" + origin_id: str = "" + is_damaged: bool = False + evaluation: str | None = None + non_smoking: bool = True + rental: bool = False + repair_history: bool = False + slug: str = "" + last_seen_at: datetime | None = None + images: list[ImageRead] = Field(default_factory=list) + diff --git a/encar_scraper/translations.py b/encar_scraper/translations.py new file mode 100644 index 0000000..08091ac --- /dev/null +++ b/encar_scraper/translations.py @@ -0,0 +1,997 @@ +# Словари переводов корейского текста русский / английский. +# Используется в encar.py для маппинга данных Encar API. +# +# Эталон переводов брендов и моделей — словарь партнёра +# (`partner_dictionary.py`). Он подмешивается ниже как база: значения оттуда +# имеют приоритет для брендов и моделей, а специфичные для проекта записи +# (корейские модели Hyundai/Kia/Genesis и т.п., которых нет у партнёра) +# сохраняются как дополнение. + +import re + +from .partner_dictionary import ENCAR_KO_EN_TABLES as _PARTNER_TABLES + + +# Топливо + +FUEL_TYPE_MAP = { + "가솔린": "Gasoline", + "gasoline": "Gasoline", + "디젤": "Diesel", + "경유": "Diesel", + "diesel": "Diesel", + "전기": "Electric", + "전기차": "Electric", + "electric": "Electric", + "하이브리드": "Hybrid", + "hybrid": "Hybrid", + "가솔린 하이브리드": "Gasoline Hybrid", + "플러그인 하이브리드": "Plug-in Hybrid", + "lpg": "LPG", + "LPG": "LPG", + "LPG(일반인 구입)": "LPG", + "LPG(장애인 구입)": "LPG", + "LNG": "LNG", + "수소": "Hydrogen", + "가솔린+전기": "Gasoline + Electric", + "가솔린 + 전기": "Gasoline + Electric", + "디젤+전기": "Hybrid", + "CNG": "CNG", +} + + +# КПП + +GEARBOX_MAP_KO = { + "오토": "AT", + "자동": "AT", + "자동변속": "AT", + "수동": "MT", + "세미오토": "AT", + "cvt": "CVT", + "CVT": "CVT", + "듀얼클러치": "AT", +} + + +# Тип продажи + +SELL_TYPE_MAP = { + "일반": "STOCK", + "경매": "AUCTION", + "입찰": "TENDER", + "리스": "STOCK", + "렌트": "STOCK", + "할부": "STOCK", +} + + +# Город → страна + +CITY_TO_COUNTRY = { + "서울": "KR", "경기": "KR", "인천": "KR", "부산": "KR", "대구": "KR", + "광주": "KR", "대전": "KR", "울산": "KR", "세종": "KR", "강원": "KR", + "충북": "KR", "충남": "KR", "전북": "KR", "전남": "KR", "경북": "KR", + "경남": "KR", "제주": "KR", +} + + +# Бренды: корейский → английский + +BRAND_TRANSLATIONS = { + "현대": "Hyundai", + "기아": "Kia", + "쉐보레(GM대우)": "Chevrolet", + "벤츠": "Mercedes-Benz", + "메르세데스-벤츠": "Mercedes-Benz", + "메르세데스 벤츠": "Mercedes-Benz", + "메르세데스-AMG": "Mercedes-AMG", + "메르세데스 AMG": "Mercedes-AMG", + "AMG": "AMG", + "BMW": "BMW", + "아우디": "Audi", + "폭스바겐": "Volkswagen", + "토요타": "Toyota", + "도요타": "Toyota", + "닛산": "Nissan", + "혼다": "Honda", + "포르쉐": "Porsche", + "랜드로버": "Land Rover", + "재규어": "Jaguar", + "볼보": "Volvo", + "푸조": "Peugeot", + "시트로엥": "Citroen", + "르노": "Renault", + "미니": "MINI", + "링컨": "Lincoln", + "캐딜락": "Cadillac", + "크라이슬러": "Chrysler", + "지프": "Jeep", + "포드": "Ford", + "테슬라": "Tesla", + "폴크스바겐": "Volkswagen", + "스코다": "Skoda", + "세아트": "SEAT", + "오펠": "Opel", + "피아트": "Fiat", + "알파로메오": "Alfa Romeo", + "마세라티": "Maserati", + "람보르기니": "Lamborghini", + "페라리": "Ferrari", + "벤틀리": "Bentley", + "롤스로이스": "Rolls-Royce", + "애스턴마틴": "Aston Martin", + "애스톤마틴": "Aston Martin", + "맥라렌": "McLaren", + "부가티": "Bugatti", + "코닉세그": "Koenigsegg", + "파가니": "Pagani", + "헤네시": "Hennessey", + "쉘비": "Shelby", + "다임러": "Daimler", + "마이바흐": "Maybach", + "스마트": "Smart", + "DS": "DS", + "렉서스": "Lexus", + "인피니티": "Infiniti", + "아큐라": "Acura", + "스바루": "Subaru", + "미쓰비시": "Mitsubishi", + "마쓰다": "Mazda", + "이스즈": "Isuzu", + "다이하츠": "Daihatsu", + "스즈키": "Suzuki", + "KG모빌리티(쌍용)": "KG Mobility", + "KG모빌리티": "KG Mobility", + "르노코리아(삼성)": "Renault Korea", + "한국GM": "GM Korea", + "쌍용": "SsangYong", + "대우": "Daewoo", + "제네시스": "Genesis", + "르노삼성": "Renault Samsung", + "로터스": "Lotus", + "리비안": "Rivian", + "루시드": "Lucid", + "리막": "Rimac", + "알파 로메오": "Alfa Romeo", + "마쯔다": "Mazda", + "어큐라": "Acura", + "다이하쯔": "Daihatsu", + # --- Новые бренды (с сайта Encar) --- + "쉐보레": "Chevrolet", + "시트로엥/DS": "Citroen/DS", + "미쯔비시": "Mitsubishi", + "미쯔오까": "Mitsuoka", + "동풍소콘": "Dongfeng Sokon", + "북기은상": "BAIC", + "신위안": "Skywell", + "이네오스": "Ineos", + "지리": "Geely", + "홍치": "Hongqi", + "BYD": "BYD", + "머큐리": "Mercury", + "사이언": "Scion", + "뷰익": "Buick", + "새턴": "Saturn", + "올즈모빌": "Oldsmobile", + "폰티악": "Pontiac", + "포톤": "Foton", + "MG로버": "MG Rover", + "닷지": "Dodge", + "램": "Ram", + "험머": "Hummer", + "폴스타": "Polestar", + "사브": "Saab", + "기타 제조사": "Other", + "기타 수입차": "Other Imported", +} + +# Подмешиваем бренды из эталонного словаря партнёра как базу. Существующие +# записи проекта имеют приоритет (не перезаписываются), новые из словаря +# партнёра добавляются. +for _ko, _en in _PARTNER_TABLES["makers"].items(): + BRAND_TRANSLATIONS.setdefault(_ko, _en) + + +# Бренды: английский → алиасы (корейский + русский) + +BRAND_ENGLISH_ALIASES: dict[str, list[str]] = { + "hyundai": ["현대", "Хёндэ"], + "kia": ["기아", "Киа"], + "genesis": ["제네시스", "Генезис"], + "chevrolet": ["쉐보레(GM대우)", "쉐보레", "Шевроле"], + "mercedes": ["벤츠", "Мерседес-Бенц"], + "mercedes-benz": ["벤츠", "Мерседес-Бенц"], + "bmw": ["BMW"], + "audi": ["아우디", "Ауди"], + "volkswagen": ["폭스바겐", "폴크스바겐", "Фольксваген"], + "toyota": ["토요타", "도요타", "Тойота"], + "nissan": ["닛산", "Ниссан"], + "honda": ["혼다", "Хонда"], + "porsche": ["포르쉐", "Порше"], + "land rover": ["랜드로버", "Ленд Ровер"], + "jaguar": ["재규어", "Ягуар"], + "volvo": ["볼보", "Вольво"], + "peugeot": ["푸조", "Пежо"], + "citroen": ["시트로엥", "Ситроен", "시트로엥/DS", "Ситроен/DS"], + "renault": ["르노", "Рено"], + "mini": ["미니", "Мини"], + "lincoln": ["링컨", "Линкольн"], + "cadillac": ["캐딜락", "Кадиллак"], + "chrysler": ["크라이슬러", "Крайслер"], + "jeep": ["지프", "Джип"], + "ford": ["포드", "Форд"], + "tesla": ["테슬라", "Тесла"], + "lexus": ["렉서스", "Лексус"], + "infiniti": ["인피니티", "Инфинити"], + "acura": ["아큐라", "어큐라", "Акура"], + "subaru": ["스바루", "Субару"], + "mitsubishi": ["미쓰비시", "미쯔비시", "Мицубиси"], + "mazda": ["마쓰다", "마쯔다", "Мазда"], + "suzuki": ["스즈키", "Сузуки"], + "ferrari": ["페라리", "Феррари"], + "lamborghini": ["람보르기니", "Ламборгини"], + "maserati": ["마세라티", "Мазерати"], + "bentley": ["벤틀리", "Бентли"], + "rolls-royce": ["롤스로이스", "Роллс-Ройс"], + "aston martin": ["애스턴마틴", "Астон Мартин"], + "mclaren": ["맥라렌", "Макларен"], + "ssangyong": ["쌍용", "СсангЙонг", "KG모빌리티(쌍용)", "KG Mobility"], + "kg mobility": ["KG모빌리티(쌍용)", "KG Mobility", "쌍용", "СсангЙонг"], + "daewoo": ["대우", "Дэу"], + "dodge": ["닷지", "Додж"], + "hummer": ["험머", "Хаммер"], + "gmc": ["GMC"], + "polestar": ["폴스타", "Полстар"], + "ds": ["DS", "시트로엥/DS", "Ситроен/DS"], + "renault korea": ["르노코리아(삼성)", "Рено Корея", "르노삼성", "Рено Самсунг"], + "saab": ["사브", "Сааб"], + "lotus": ["로터스", "Лотус"], + "alfa romeo": ["알파로메오", "Альфа Ромео", "알파 로메오"], + "fiat": ["피아트", "Фиат"], + "smart": ["스마트", "Смарт"], + "skoda": ["스코다", "Шкода"], + "daihatsu": ["다이하츠", "다이하쯔", "Дайхатсу"], + "koenigsegg": ["코닉세그", "Кёнигсегг"], + "pagani": ["파가니", "Пагани"], + "bugatti": ["부가티", "Бугатти"], + "maybach": ["마이바흐", "Майбах"], + "rivian": ["리비안", "Ривиан"], + "lucid": ["루시드", "Люсид"], + "rimac": ["리막", "Римак"], + "byd": ["BYD"], + "geely": ["지리", "Джили"], + "dongfeng": ["동풍소콘", "Донгфенг Сокон"], + "baic": ["북기은상", "BAIC"], + "skywell": ["신위안", "Skywell"], + "ineos": ["이네오스", "Инеос"], + "mercury": ["머큐리", "Меркурий"], + "scion": ["사이언", "Scion"], + "buick": ["뷰익", "Бьюик"], + "saturn": ["새턴", "Saturn"], + "oldsmobile": ["올즈모빌", "Oldsmobile"], + "pontiac": ["폰티악", "Понтиак"], + "foton": ["포톤", "Foton"], + "mg rover": ["MG로버", "MG Rover"], + "mitsuoka": ["미쯔오까", "Мицуока"], + "other": ["기타 제조사", "기타 수입차", "Другие", "Другие импортные"], +} + + +def expand_allowed_brands(brands: set[str] | None) -> set[str] | None: + """Расширяет набор английских брендов всеми известными alias'ами (корейский + русский).""" + if not brands: + return brands + expanded = set(brands) + for brand in brands: + aliases = BRAND_ENGLISH_ALIASES.get(brand.lower(), []) + for alias in aliases: + expanded.add(alias.lower()) + return expanded + + + +# Модели / комплектации: корейский → английский + +# Точные переводы базовых названий моделей Encar. Этот словарь используется +# перед частичным переводом и не должен содержать общие слова вроде "뉴". +MODEL_EXACT_TRANSLATIONS = { + # BMW / Mercedes-Benz + "1시리즈": "1 Series", + "2시리즈": "2 Series", + "3시리즈": "3 Series", + "4시리즈": "4 Series", + "5시리즈": "5 Series", + "6시리즈": "6 Series", + "7시리즈": "7 Series", + "8시리즈": "8 Series", + "A-클래스": "A-Class", + "B-클래스": "B-Class", + "C-클래스": "C-Class", + "E-클래스": "E-Class", + "S-클래스": "S-Class", + "SL-클래스": "SL-Class", + "SLK-클래스": "SLK-Class", + "GLA-클래스": "GLA-Class", + "GLB-클래스": "GLB-Class", + "GLC-클래스": "GLC-Class", + "GLE-클래스": "GLE-Class", + "GLS-클래스": "GLS-Class", + "V-클래스": "V-Class", + # Hyundai / Kia / Genesis + "팰리세이드": "Palisade", + "싼타페": "Santa Fe", + "투싼": "Tucson", + "아반떼": "Avante", + "쏘나타": "Sonata", + "쏘나타 디 엣지": "Sonata The Edge", + "그랜저": "Grandeur", + "아이오닉5": "Ioniq 5", + "아이오닉6": "Ioniq 6", + "아이오닉": "Ioniq", + "넥쏘": "Nexo", + "벨로스터": "Veloster", + "코나": "Kona", + "캐스퍼": "Casper", + "베뉴": "Venue", + "스타렉스": "Starex", + "스타리아": "Staria", + "아슬란": "Aslan", + "베라크루즈": "Veracruz", + "카니발": "Carnival", + "쏘렌토": "Sorento", + "스포티지": "Sportage", + "에스컬레이드": "Escalade", + "프리미어": "Premier", + "1세대": "1st gen", + "2세대": "2nd gen", + "3세대": "3rd gen", + "4세대": "4th gen", + "5세대": "5th gen", + "6세대": "6th gen", + "7세대": "7th gen", + "8세대": "8th gen", + "모닝": "Morning", + "레이": "Ray", + "셀토스": "Seltos", + "니로": "Niro", + "스팅어": "Stinger", + "쏘울": "Soul", + "모하비": "Mohave", + "프라이드": "Pride", + "스토닉": "Stonic", + "카렌스": "Carens", + "제네시스": "Genesis", + # Chevrolet / Renault Korea / KG Mobility + "스파크": "Spark", + "말리부": "Malibu", + "트랙스": "Trax", + "크루즈": "Cruze", + "올란도": "Orlando", + "트레일블레이저": "Trailblazer", + "콜로라도": "Colorado", + "트래버스": "Traverse", + "캡티바": "Captiva", + "이쿼녹스": "Equinox", + "아베오": "Aveo", + "볼트": "Volt", + "임팔라": "Impala", + "맥스크루즈": "Max Cruz", + "렉스턴 스포츠 칸": "Rexton Sport Khan", + "렉스턴 스포츠": "Rexton Sport", + "렉스턴": "Rexton", + "토레스": "Torres", + "티볼리 에어": "Tivoli Air", + "티볼리 아머": "Tivoli Armor", + "티볼리": "Tivoli", + "코란도 투리스모": "Korando Turismo", + "코란도 스포츠": "Korando Sport", + "코란도": "Korando", + "체어맨": "Chairman", + "오피러스": "Opirus", + # Import models written in Korean + "익스플로러": "Explorer", + "머스탱": "Mustang", + "카이엔": "Cayenne", + "카이엔 쿠페": "Cayenne Coupe", + "파나메라": "Panamera", + "파나메라 스포츠 투리스모": "Panamera Sport Turismo", + "마칸": "Macan", + "타이칸": "Taycan", + "타이칸 크로스 투리스모": "Taycan Cross Turismo", + "918 스파이더": "918 Spyder", + "718 스파이더": "718 Spyder", + "4.0 스파이더": "4.0 Spyder", + "박스터": "Boxster", + "카이맨": "Cayman", + "911 카브리올레": "911 Cabriolet", + "카레라": "Carrera", + "티구안": "Tiguan", + "골프": "Golf", + "제타": "Jetta", + "투아렉": "Touareg", + "파사트": "Passat", + "아테온": "Arteon", + "쿠퍼": "Cooper", + "컨트리맨": "Countryman", + "클럽맨": "Clubman", + "디스커버리": "Discovery", + "레인지로버": "Range Rover", + "이보크": "Evoque", + "벨라": "Velar", + "디펜더": "Defender", + "랭글러": "Wrangler", + "체로키": "Cherokee", + "레니게이드": "Renegade", + "컴패스": "Compass", + "루비콘": "Rubicon", + "에비에이터": "Aviator", + "르반떼": "Levante", + "기블리": "Ghibli", + "어코드": "Accord", + "캠리": "Camry", + "프리우스": "Prius", + "타스만": "Tasman", + "토러스": "Taurus", + "카마로": "Camaro", + "비틀": "Beetle", + "e-트론": "e-tron", + "글래디에이터": "Gladiator", + "노틸러스": "Nautilus", + "시로코": "Scirocco", + "아틀라스": "Atlas", + "알파드": "Alphard", + "익스페디션": "Expedition", + "익스프레스Van": "Express Van", + "짐니": "Jimny", + "쿠가": "Kuga", + "클리오": "Clio", + "파일럿": "Pilot", + "콰트로포르테": "Quattroporte", + "고스트": "Ghost", + "팬텀": "Phantom", + "레이스": "Wraith", + "던": "Dawn", + "컬리넌": "Cullinan", + "스펙터": "Spectre", + "벤테이가": "Bentayga", + "컨티넨탈 GT": "Continental GT", + "플라잉스퍼": "Flying Spur", + "우라칸": "Huracan", + "아벤타도르": "Aventador", + "우루스": "Urus", + "레부엘토": "Revuelto", + "로마": "Roma", + "포르토피노": "Portofino", + "SF90 스트라달레": "SF90 Stradale", + "F8 트리뷰토": "F8 Tributo", + "그란투리스모": "GranTurismo", + "반퀴시": "Vanquish", + "밴티지": "Vantage", + "AMG GT 4도어 쿠페": "AMG GT 4-Door Coupe", +} + +# Подмешиваем модели из эталонного словаря партнёра как базу. Существующие +# записи проекта имеют приоритет. Дополнительно добавляем нормализованные +# варианты ключей партнёра (напр. "5 시리즈" -> "5시리즈", "S 클래스" -> "S-클래스"), +# т.к. в сырых данных Encar формат отличается пробелами/дефисами. +def _register_partner_models() -> None: + for _ko, _en in _PARTNER_TABLES["models"].items(): + MODEL_EXACT_TRANSLATIONS.setdefault(_ko, _en) + # "5 시리즈" -> "5시리즈" + _compact = re.sub(r"(?<=\d)\s+시리즈", "시리즈", _ko) + # "S 클래스" -> "S-클래스" + _compact = re.sub(r"(?<=[A-Za-z])\s+클래스", "-클래스", _compact) + if _compact != _ko: + MODEL_EXACT_TRANSLATIONS.setdefault(_compact, _en) + + +_register_partner_models() + +# Безопасные составные фразы. Применяются только к строкам, где есть корейский текст. +MODEL_PHRASE_TRANSLATIONS = { + "올 뉴": "All New", + "더 뉴": "New", + "디 올 뉴": "The All New", + "베리 뉴": "Very New", + "베리": "Very", + "뷰티풀": "Beautiful", + "넥스트": "Next", + "디 ": "The ", + "디 엣지": "The Edge", + "액티브 투어러": "Active Tourer", + "투어러": "Tourer", + "그란쿠페": "GranCoupe", + "그란 쿠페": "GranCoupe", + "스포츠 칸": "Sport Khan", + "스포츠": "Sport", + "하이브리드": "Hybrid", + "일렉트릭": "Electric", + "네오": "Neo", + "트랜스폼": "Transform", + "프라임": "Prime", + "마스터": "Master", + "어반": "Urban", + "볼드": "Bold", + "아머": "Armor", + "에어": "Air", +} + +# Короткие токены переводятся только как отдельные токены, не внутри других слов. +MODEL_TOKEN_TRANSLATIONS = { + "시리즈": "Series", + "클래스": "Class", + "세대": "gen", + "뉴": "New", + "올": "All", + "칸": "Khan", +} + +MODEL_TRANSLATIONS = { + # --- Топливо / тип двигателя --- + "가솔린": "Gasoline", + "디젤": "Diesel", + "하이브리드": "Hybrid", + "전기": "Electric", + "터보": "Turbo", + "일렉트릭": "Electric", + "일렉트리파이드": "Electrified", + "블루텍": "BlueTEC", + "블루모션": "BlueMotion", + # --- Кузов / конструкция --- + "쿠페": "Coupe", + "세단": "Sedan", + "왜건": "Wagon", + "웨건": "Wagon", + "해치백": "Hatchback", + "컨버터블": "Convertible", + "카브리올레": "Cabriolet", + "크로스오버": "Crossover", + "리무진": "Limousine", + "하이리무진": "High Limousine", + "하이리무진(특장업체)": "High Limousine (Special)", + "스포트백": "Sportback", + "스파이더": "Spyder", + "밴": "Van", + "픽업": "Pickup", + "캠핑카": "Camper", + # --- КПП / привод --- + "오토": "Auto", + "수동": "Manual", + "후륜": "RWD", + "전륜": "FWD", + "사륜": "AWD", + "4모션": "4Motion", + # --- Трим / комплектация --- + "프레스티지": "Prestige", + "시그니처": "Signature", + "럭셔리": "Luxury", + "스포츠": "Sport", + "롱레인지": "Long Range", + "스탠다드": "Standard", + "플러스": "Plus", + "리미티드": "Limited", + "스페셜": "Special", + "모던": "Modern", + "익스클루시브": "Exclusive", + "캘리그래피": "Calligraphy", + "노블레스": "Noblesse", + "트림": "Trim", + "프리미엄": "Premium", + "프리미어": "Premier", + "플래티넘": "Platinum", + "플래티넘Ⅱ": "Platinum II", + "인스퍼레이션": "Inspiration", + "트렌디": "Trendy", + "다이나믹": "Dynamic", + "프라임": "Prime", + "초이스": "Choice", + "르블랑": "Le Blanc", + "프레지던트": "President", + "인스크립션": "Inscription", + "볼드": "Bold", + "밸류": "Value", + "퓨어": "Pure", + "엑셀런스": "Excellence", + "얼티메이트": "Ultimate", + "에센셜": "Essential", + "슈프림": "Supreme", + "에어": "Air", + "어반": "Urban", + "탑": "Top", + "코어": "Core", + "엘리트": "Elite", + "셀렉션": "Selection", + "브라이트": "Bright", + "클래식": "Classic", + "고급형": "Deluxe", + "최고급형": "Top Grade", + "기본형": "Base", + "디럭스": "Deluxe", + "디럭스팩": "Deluxe Pack", + "마이스터": "Meister", + "마스터": "Master", + "마스터즈": "Masters", + "어메이징": "Amazing", + "파워": "Power", + "리저브": "Reserve", + "퍼펙트": "Perfect", + "리얼": "Real", + "울트라": "Ultra", + "세이프티": "Safety", + "퍼포먼스": "Performance", + "인텔리전트": "Intelligent", + "컬렉션": "Collection", + "브릴리언트": "Brilliant", + "어드밴티지": "Advantage", + "파이니스트": "Finest", + "런치팩": "Launch Pack", + "페이버드": "Favored", + "엘레강스": "Elegance", + "하이": "High", + "블랙라벨": "Black Label", + "플럭스": "Flux", + "테크": "Tech", + "프로": "Pro", + "베스트": "Best", + "라이즈": "Rise", + "액티브": "Active", + "롱": "Long", + "패키지": "Package", + "에디션": "Edition", + "디자인": "Design", + "라운지": "Lounge", + "아트": "Art", + "팝": "Pop", + "조이": "Joy", + "레드라인": "Redline", + "스타일": "Style", + "블랙": "Black", + "라이트": "Light", + "온라인": "Online", + "론지튜드": "Longitude", + "오버랜드": "Overland", + "컴페티션": "Competition", + "올스페이스": "Allspace", + "스타": "Star", + # --- Поколение / номер --- + "1세대": "1st gen", + "2세대": "2nd gen", + "3세대": "3rd gen", + "4세대": "4th gen", + "5세대": "5th gen", + "6세대": "6th gen", + "7세대": "7th gen", + "8세대": "8th gen", + "10세대": "10th gen", + "더": "", + "뉴": "New", + "넥스트": "Next", + "올": "All", + "그란": "Gran", + "그랜드": "Grand", + # --- Посадочных мест --- + "2인승": "2-seater", + "3인승": "3-seater", + "5인승": "5-seater", + "6인승": "6-seater", + "7인승": "7-seater", + "9인승": "9-seater", + "11인승": "11-seater", + "12인승": "12-seater", + # --- Двери --- + "4도어": "4-door", + "5도어": "5-door", + # --- Модели Hyundai --- + "팰리세이드": "Palisade", + "싼타페": "Santa Fe", + "투싼": "Tucson", + "아반떼": "Avante", + "쏘나타": "Sonata", + "그랜저": "Grandeur", + "아이오닉5": "Ioniq 5", + "아이오닉6": "Ioniq 6", + "아이오닉": "Ioniq", + "넥쏘": "Nexo", + "벨로스터": "Veloster", + "코나": "Kona", + "캐스퍼": "Casper", + "베뉴": "Venue", + "스타렉스": "Starex", + "스타리아": "Staria", + "에쿠스(신형)": "Equus", + "엑센트(신형)": "Accent", + "아슬란": "Aslan", + "베라크루즈": "Veracruz", + # --- Модели Kia --- + "카니발": "Carnival", + "쏘렌토": "Sorento", + "스포티지": "Sportage", + "모닝": "Morning", + "뉴모닝": "New Morning", + "레이": "Ray", + "셀토스": "Seltos", + "니로": "Niro", + "스팅어": "Stinger", + "쏘울": "Soul", + "모하비": "Mohave", + "프라이드": "Pride", + "스토닉": "Stonic", + # --- Модели Chevrolet/GM --- + "스파크": "Spark", + "말리부": "Malibu", + "트랙스": "Trax", + "크루즈": "Cruze", + "올란도": "Orlando", + "트레일블레이저": "Trailblazer", + "콜로라도": "Colorado", + "트래버스": "Traverse", + "캡티바": "Captiva", + "이쿼녹스": "Equinox", + "아베오": "Aveo", + "볼트": "Volt", + "임팔라": "Impala", + "맥스크루즈": "Max Cruz", + # --- Модели Genesis --- + "제네시스": "Genesis", + # --- Модели иностранных авто --- + "익스플로러": "Explorer", + "머스탱": "Mustang", + "카이엔": "Cayenne", + "파나메라": "Panamera", + "마칸": "Macan", + "타이칸": "Taycan", + "박스터": "Boxster", + "카이맨": "Cayman", + "카레라": "Carrera", + "티구안": "Tiguan", + "골프": "Golf", + "제타": "Jetta", + "투아렉": "Touareg", + "파사트": "Passat", + "아테온": "Arteon", + "콰트로": "quattro", + "아방가르드": "Avantgarde", + "쿠퍼": "Cooper", + "컨트리맨": "Countryman", + "클럽맨": "Clubman", + "디스커버리": "Discovery", + "레인지로버": "Range Rover", + "이보크": "Evoque", + "벨라": "Velar", + "디펜더": "Defender", + "랭글러": "Wrangler", + "체로키": "Cherokee", + "체로키(KL)": "Cherokee (KL)", + "체로키(WL)": "Cherokee (WL)", + "레니게이드": "Renegade", + "컴패스": "Compass", + "루비콘": "Rubicon", + "에비에이터": "Aviator", + "에스컬레이드": "Escalade", + "컨티넨탈": "Continental", + "르반떼": "Levante", + "기블리": "Ghibli", + "알티마": "Altima", + "오딧세이": "Odyssey", + "어코드": "Accord", + "캠리": "Camry", + "시에나": "Sienna", + "프리우스": "Prius", + "크로스컨트리": "Cross Country", + "타스만": "Tasman", + "고스트": "Ghost", + "팬텀": "Phantom", + "레이스": "Wraith", + "던": "Dawn", + "컬리넌": "Cullinan", + "스펙터": "Spectre", + "벤테이가": "Bentayga", + "컨티넨탈 GT": "Continental GT", + "플라잉스퍼": "Flying Spur", + "우라칸": "Huracan", + "아벤타도르": "Aventador", + "우루스": "Urus", + "레부엘토": "Revuelto", + "로마": "Roma", + "포르토피노": "Portofino", + "SF90 스트라달레": "SF90 Stradale", + "F8 트리뷰토": "F8 Tributo", + "콰트로포르테": "Quattroporte", + "그란투리스모": "GranTurismo", + "반퀴시": "Vanquish", + "밴티지": "Vantage", + # --- Дополнительные модели, встреченные в данных Encar --- + "시에라": "Sierra", + "시빅": "Civic", + "레인저": "Ranger", + "콜레오스": "Koleos", + "그랑 콜레오스": "Grand Koleos", + "페이톤": "Phaeton", + "티록": "T-Roc", + "큐브": "Cube", + "크라운": "Crown", + "라이즈": "Rise", + "시로코": "Scirocco", + "이오스": "Eos", + "비틀": "Beetle", + "폴로": "Polo", + # --- Служебные / категории --- + "(세부등급": "(sub-trim", + "없음)": "none)", + "(렌터카)": "(rental)", + "(렌터카용)": "(for rental)", + "LPG(렌터카용)": "LPG (for rental)", + "LPI(렌터카용)": "LPI (for rental)", + "(택시형)": "(taxi)", + "택시형": "taxi", + "어린이보호차": "child transport", + "카고": "Cargo", + "모델": "Model", + "스마트": "Smart", + "기아": "Kia", + "마이바흐": "Maybach", + "레인지": "Range", +} + + +# Цвета: корейский → английский + +COLOR_TRANSLATIONS = { + "흰색": "white", + "검정색": "black", + "회색": "gray", + "은색": "silver", + "진회색": "dark gray", + "밝은 회색": "light gray", + "청색": "blue", + "파란색": "blue", + "진청색": "dark blue", + "밝은 청색": "light blue", + "녹색": "green", + "초록색": "green", + "진녹색": "dark green", + "밝은 녹색": "light green", + "적색": "red", + "빨간색": "red", + "진적색": "dark red", + "밝은 적색": "light red", + "주황색": "orange", + "노란색": "yellow", + "갈색": "brown", + "베이지색": "beige", + "아이보리": "ivory", + "크림색": "cream", + "보라색": "purple", + "분홍색": "pink", + "금색": "gold", + "청금색": "blue-gold", + "청은색": "blue-silver", + "흑청색": "black-blue", + "흑적색": "black-red", + "흑녹색": "black-green", + "흑회색": "black-gray", + "흰청색": "white-blue", + "흰적색": "white-red", + "흰녹색": "white-green", + "흰회색": "white-gray", + "청적색": "blue-red", + "청녹색": "blue-green", + "청회색": "blue-gray", + "적녹색": "red-green", + "적회색": "red-gray", + "녹회색": "green-gray", + "기타": "other", + "기타색상": "other", + "특별색상": "special", + "무채색": "neutral", + "메탈릭": "metallic", + "펄": "pearl", + "무광": "matte", + "광택": "glossy", +} + + +# Тип кузова: корейский → enum + +BODY_TYPE_MAP = { + "세단": "SEDAN", + "쿠페": "COUPE", + "컨버터블": "CONVERTIBLE", + "카브리올레": "CONVERTIBLE", + "오픈카": "OPEN", + "해치백": "HATCHBACK", + "왜건": "WAGON", + "스테이션왜건": "STATION_WAGON", + "SUV": "SUV", + "SUV ": "SUV", + "크로스오버": "SUV", + "픽업트럭": "PICKUP", + "픽업": "PICKUP", + "밴": "VAN", + "미니밴": "MINIVAN", + "트럭": "TRUCK", + "쿠페형 SUV": "SUV", + "버스": "BUS", + "기타": "OTHER", + "승용차": "SEDAN", + "승합차": "VAN", + "화물차": "TRUCK", + "특장차": "SPECIAL", + "경차": "COMPACT", + "중형차": "MID_SIZE", + "대형차": "FULL_SIZE", + "스포츠카": "SPORTS", + "클래식카": "CLASSIC", + "전기차": "ELECTRIC", + "하이브리드": "HYBRID", +} + + +# Определение body_type по модели (fallback) + +MODEL_BODY_TYPE_MAP: dict[str, str] = { + # SUV / кроссоверы + "투싼": "SUV", "싼타페": "SUV", "팰리세이드": "SUV", "코나": "SUV", "베뉴": "SUV", + "크레타": "SUV", "넥쏘": "SUV", "아이오닉5": "SUV", "아이오닉6": "SEDAN", + "셀토스": "SUV", "스포티지": "SUV", "쏘렌토": "SUV", "모하비": "SUV", + "니로": "SUV", "EV6": "SUV", "EV9": "SUV", + "티볼리": "SUV", "코란도": "SUV", "렉스턴": "SUV", "토레스": "SUV", "액티언": "SUV", + "GV70": "SUV", "GV80": "SUV", "GV60": "SUV", + "X1": "SUV", "X2": "SUV", "X3": "SUV", "X4": "SUV", "X5": "SUV", "X6": "SUV", "X7": "SUV", "iX": "SUV", "iX3": "SUV", + "Q2": "SUV", "Q3": "SUV", "Q4": "SUV", "Q5": "SUV", "Q7": "SUV", "Q8": "SUV", + "GLA": "SUV", "GLB": "SUV", "GLC": "SUV", "GLE": "SUV", "GLS": "SUV", + "카이엔": "SUV", "마칸": "SUV", + "레인지로버": "SUV", "디스커버리": "SUV", "디펜더": "SUV", + "래브4": "SUV", "하이랜더": "SUV", "FJ 크루져": "SUV", + "CR-V": "SUV", "HR-V": "SUV", + "티구안": "SUV", "투아렉": "SUV", "아틀라스": "SUV", "ID.4": "SUV", + "트레일블레이저": "SUV", "이쿼녹스": "SUV", "트래버스": "SUV", "트랙스": "SUV", "캡티바": "SUV", "올란도": "SUV", + "XC40": "SUV", "XC60": "SUV", "XC90": "SUV", "V40": "HATCHBACK", "V60": "STATION_WAGON", "V90": "STATION_WAGON", + "F-PACE": "SUV", "E-PACE": "SUV", "I-PACE": "SUV", + "모델 Y": "SUV", "Model Y": "SUV", "모델 X": "SUV", "Model X": "SUV", + "RX": "SUV", "NX": "SUV", "UX": "SUV", "LX": "SUV", + "QX50": "SUV", "QX60": "SUV", "QX80": "SUV", + "QM3": "SUV", "QM6": "SUV", + "레니게이드": "SUV", "컴패스": "SUV", "체로키": "SUV", "랭글러": "SUV", "글래디에이터": "PICKUP", + "에비에이터": "SUV", "노틸러스": "SUV", "코세어": "SUV", + "르반떼": "SUV", "그레칼레": "SUV", + "Z4": "CONVERTIBLE", + # 세단 + "쏘나타": "SEDAN", "아반떼": "SEDAN", "그랜저": "SEDAN", "K5": "SEDAN", + "K8": "SEDAN", "K9": "SEDAN", "K3": "SEDAN", "K7": "SEDAN", + "G70": "SEDAN", "G80": "SEDAN", "G90": "SEDAN", + "1시리즈": "HATCHBACK", + "2시리즈 그란쿠페": "SEDAN", "2시리즈 액티브투어러": "MINIVAN", + "3시리즈": "SEDAN", "3시리즈 GT": "SEDAN", + "5시리즈": "SEDAN", "7시리즈": "SEDAN", "i4": "SEDAN", "i7": "SEDAN", + "A1": "HATCHBACK", "A3": "SEDAN", "A4": "SEDAN", "A6": "SEDAN", "A7": "SEDAN", "A8": "SEDAN", + "A-클래스": "SEDAN", "CLA": "SEDAN", + "C-클래스": "SEDAN", "E-클래스": "SEDAN", "S-클래스": "SEDAN", + "캠리": "SEDAN", "아발론": "SEDAN", "프리우스": "SEDAN", + "어코드": "SEDAN", "시빅": "SEDAN", + "파사트": "SEDAN", "제타": "SEDAN", "아테온": "SEDAN", + "말리부": "SEDAN", "크루즈": "SEDAN", "임팔라": "SEDAN", + "S60": "SEDAN", "S90": "SEDAN", + "IS": "SEDAN", "ES": "SEDAN", "GS": "SEDAN", "LS": "SEDAN", + "Q50": "SEDAN", "Q70": "SEDAN", + "모델 3": "SEDAN", "Model 3": "SEDAN", "모델 S": "SEDAN", "Model S": "SEDAN", + "SM6": "SEDAN", "SM5": "SEDAN", + # 해치백 + "스파크": "HATCHBACK", "모닝": "HATCHBACK", "레이": "HATCHBACK", + "i30": "HATCHBACK", "벨로스터": "HATCHBACK", + "골프": "HATCHBACK", "폴로": "HATCHBACK", "ID.3": "HATCHBACK", + "캐스퍼": "HATCHBACK", + # 미니밴/밴 + "카니발": "MINIVAN", "스타리아": "MINIVAN", "스타렉스": "MINIVAN", + "V-클래스": "MINIVAN", "비토": "VAN", + "시에나": "MINIVAN", "알파드": "MINIVAN", + # 트럭/픽업 + "포터": "PICKUP", "봉고": "PICKUP", "마이티": "TRUCK", + "타코마": "PICKUP", "터프": "PICKUP", + "콜로라도": "PICKUP", "실버라도": "PICKUP", + # 쿠페 + "4시리즈": "COUPE", "8시리즈": "COUPE", "2시리즈": "COUPE", + "A5": "COUPE", "TT": "COUPE", + "RC": "COUPE", "LC": "COUPE", +} diff --git a/encar_scraper/worker/__init__.py b/encar_scraper/worker/__init__.py new file mode 100644 index 0000000..841be13 --- /dev/null +++ b/encar_scraper/worker/__init__.py @@ -0,0 +1,3 @@ +from .celery_app import celery_app + +__all__ = ["celery_app"] diff --git a/encar_scraper/worker/celery_app.py b/encar_scraper/worker/celery_app.py new file mode 100644 index 0000000..0dd4e23 --- /dev/null +++ b/encar_scraper/worker/celery_app.py @@ -0,0 +1,111 @@ +# Инициализация Celery-приложения и периодических задач для Encar. + +import logging + +from celery import Celery +from celery.signals import beat_init +from redis import Redis +from sqlalchemy import func, select + +from ..core.config import settings + +logger = logging.getLogger("encar_scraper.worker.celery_app") +_INITIAL_SYNC_BOOTSTRAP_KEY = "encar:bootstrap:initial_sync_enqueued" + + +def _broker_url() -> str: + return settings.celery.broker_url or settings.redis.url + + +def _result_backend() -> str: + return settings.celery.result_backend or settings.redis.url + + +celery_app = Celery( + "encar_scraper", + broker=_broker_url(), + backend=_result_backend(), +) + +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + task_soft_time_limit=settings.celery.task_soft_time_limit, + task_time_limit=settings.celery.task_time_limit, + task_acks_late=True, + task_reject_on_worker_lost=True, + task_track_started=True, + worker_concurrency=settings.celery.worker_concurrency, + worker_max_tasks_per_child=settings.celery.worker_max_tasks_per_child, + worker_pool="prefork", + worker_prefetch_multiplier=1, + broker_connection_retry_on_startup=True, + broker_transport_options={ + "visibility_timeout": settings.celery.broker_visibility_timeout, + }, + result_expires=86400, + worker_hijack_root_logger=False, + # Следующий запуск планирует сама задача в finally через apply_async(countdown=...), + # чтобы час отсчитывался от завершения прогона, а не от старта. + beat_schedule={}, + task_routes={ + "encar_scraper.worker.tasks.*": {"queue": "encar"}, + }, +) + +celery_app.autodiscover_tasks(["encar_scraper.worker"]) + + +@beat_init.connect +def _schedule_initial_sync_on_first_start(**kwargs): + """Ставит первый sync сразу после первого запуска проекта на пустой БД.""" + from ..storage.db import PersistenceService + from ..storage.models import SyncRun + from .tasks import encar_sync_listing_task + + persistence = PersistenceService(settings) + redis_client: Redis | None = None + try: + with persistence.session_scope() as session: + total_runs = session.execute(select(func.count(SyncRun.id))).scalar() or 0 + + if total_runs: + return + + redis_client = Redis.from_url( + _broker_url(), + decode_responses=True, + socket_timeout=10, + socket_connect_timeout=5, + ) + marked = redis_client.set(_INITIAL_SYNC_BOOTSTRAP_KEY, "1", nx=True, ex=600) + if not marked: + logger.info("Initial Encar sync already bootstrapped on this startup") + return + + encar_sync_listing_task.apply_async(kwargs={"car_type": "all"}, queue="encar") + logger.info("Scheduled initial Encar sync because sync_runs table is empty") + except Exception: + logger.warning("Failed to schedule initial Encar sync", exc_info=True) + finally: + persistence.engine.dispose() + if redis_client is not None: + try: + redis_client.close() + except Exception: + pass + + +from celery.signals import setup_logging as celery_setup_logging + + +@celery_setup_logging.connect +def _configure_logging(loglevel, logfile, format, colorize, **kwargs): + """Перехватываем настройку логирования Celery, чтобы использовать свой формат.""" + from ..core.logs import setup_logging + import logging + level_name = logging.getLevelName(loglevel) if isinstance(loglevel, int) else str(loglevel) + setup_logging(level=level_name, log_file=logfile) \ No newline at end of file diff --git a/encar_scraper/worker/tasks.py b/encar_scraper/worker/tasks.py new file mode 100644 index 0000000..6f3d958 --- /dev/null +++ b/encar_scraper/worker/tasks.py @@ -0,0 +1,319 @@ +# Celery tasks для синхронизации автомобилей Encar. + +import logging +from threading import Event, Thread +import uuid + +from celery import shared_task +from celery.exceptions import MaxRetriesExceededError, Retry +from celery.signals import worker_process_init +from redis import Redis + +from ..core.config import Settings +from ..core.runtime_config import load_runtime_config +from ..encar import EncarScraper, EncarFilters, expand_allowed_brands +from ..storage.db import PersistenceService + +logger = logging.getLogger("encar_scraper.worker.tasks") + +ENCAR_SYNC_LOCK_KEY = "encar:locks:sync_listing" + +_persistence: PersistenceService | None = None +_redis: Redis | None = None + + +@worker_process_init.connect +def _reset_globals_after_fork(**kwargs): + global _persistence, _redis + # После fork закрываем унаследованные TCP-сокеты родителя (иначе дети + # делят одни connection pool'ы → повреждение данных). + if _persistence is not None: + try: + _persistence.engine.dispose() + except Exception: + logger.debug("engine.dispose after fork failed", exc_info=True) + if _redis is not None: + try: + _redis.close() + except Exception: + logger.debug("redis.close after fork failed", exc_info=True) + _persistence = None + _redis = None + + +def _sync_listing_lock_ttl_seconds() -> int: + settings = Settings() + return max(settings.celery.task_time_limit + 120, 300) + + +def _get_persistence() -> PersistenceService: + global _persistence + if _persistence is None: + _persistence = PersistenceService(Settings()) + return _persistence + + +def _get_redis() -> Redis: + global _redis + if _redis is None: + settings = Settings() + _redis = Redis.from_url( + settings.redis.url, + decode_responses=True, + socket_timeout=10, + socket_connect_timeout=5, + ) + return _redis + + +def _acquire_lock(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool: + try: + return bool(redis_client.set(key, owner_token, nx=True, ex=ttl_seconds)) + except Exception as exc: + logger.warning("Failed to acquire lock %s", key, exc_info=True) + return False + + +_REFRESH_LOCK_SCRIPT = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])) +end +return 0 +""" + +_RELEASE_LOCK_SCRIPT = """ +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +""" + + +def _refresh_lock_if_owner(redis_client: Redis, key: str, owner_token: str, ttl_seconds: int) -> bool | None: + try: + script = redis_client.register_script(_REFRESH_LOCK_SCRIPT) + refreshed = script(keys=[key], args=[owner_token, int(ttl_seconds)]) + return bool(refreshed) + except Exception as exc: + logger.warning("Failed to refresh lock %s", key, exc_info=True) + return None + + +def _release_lock_if_owner(redis_client: Redis, key: str, owner_token: str) -> None: + try: + script = redis_client.register_script(_RELEASE_LOCK_SCRIPT) + script(keys=[key], args=[owner_token]) + except Exception as exc: + logger.warning("Failed to release lock %s", key, exc_info=True) + + +def _start_lock_heartbeat( + redis_client: Redis, + key: str, + owner_token: str, + ttl_seconds: int, +) -> tuple[Event, Thread]: + stop_event = Event() + interval_seconds = max(5.0, min(30.0, ttl_seconds / 3)) + + def _heartbeat() -> None: + while not stop_event.wait(interval_seconds): + refreshed = _refresh_lock_if_owner(redis_client, key, owner_token, ttl_seconds) + if refreshed is False: + logger.warning("Lost sync_listing lock ownership for %s", owner_token) + return + + thread = Thread(target=_heartbeat, name="encar-lock-heartbeat", daemon=True) + thread.start() + return stop_event, thread + + +@shared_task( + name="encar_scraper.worker.tasks.encar_sync_listing_task", + bind=True, + max_retries=2, + default_retry_delay=60, + acks_late=True, +) +def encar_sync_listing_task( + self, + car_type: str = "all", + manufacturer: str | None = None, + year_from: int | None = None, + year_to: int | None = None, + price_max: int | None = None, + lane: str = "encar", + limit: int | None = None, +): + """ + Полная синхронизация листинга Encar. + Собирает ВСЕ авто, обновляет цены/данные, помечает проданные. + """ + persistence = _get_persistence() + persistence.create_tables() + task_id = self.request.id or "unknown" + owner_token = f"{task_id}:{uuid.uuid4().hex}" + redis_client = _get_redis() + + lock_ttl = _sync_listing_lock_ttl_seconds() + lock_acquired = _acquire_lock(redis_client, ENCAR_SYNC_LOCK_KEY, owner_token, lock_ttl) + + if not lock_acquired: + logger.info("encar_sync_listing_task skipped: another sync is already running") + return { + "status": "skipped", + "reason": "sync_already_running", + "task_id": task_id, + } + + heartbeat_stop: Event | None = None + heartbeat_thread: Thread | None = None + run_id: int | None = None + # Планировать следующий прогон только когда текущий прогон завершён + # (success или final failure после исчерпания retries). Retry сам + # перепланирует задачу — не дублируем apply_async в этом случае. + reschedule_next = True + + try: + heartbeat_stop, heartbeat_thread = _start_lock_heartbeat( + redis_client, + ENCAR_SYNC_LOCK_KEY, + owner_token, + lock_ttl, + ) + self.update_state(state="STARTED", meta={"stage": "encar_sync_started", "task_id": task_id}) + + # --- Загрузка runtime_config --- + rc = load_runtime_config() + effective_limit = limit or rc.sync.limit + effective_lane = lane or rc.sync.lane or "encar" + + # --- Фиксируем начало sync_run --- + run_id = persistence.start_sync_run(effective_lane) + + car_type_map = {"all": "A", "domestic": "Y", "import": "N"} + filters = EncarFilters( + car_type=car_type_map.get(car_type, "A"), + manufacturer=manufacturer, + year_from=year_from, + year_to=year_to, + price_to=price_max, + ) + + # Набор разрешённых брендов (lowercased) из runtime_config + # Расширяем английские имена alias'ами (корейский + русский перевод) + allowed_brands = expand_allowed_brands( + set(rc.filters.brands) if rc.filters.brands else None + ) + excluded_brands = expand_allowed_brands( + set(rc.filters.exclude_brands) if rc.filters.exclude_brands else None + ) + + scraper = EncarScraper(persistence=persistence) + result = scraper.sync_listing( + limit=effective_limit, + filters=filters, + lane=effective_lane, + redis_client=redis_client, + allowed_brands=allowed_brands, + excluded_brands=excluded_brands, + runtime_filters=rc.filters, + probe_all_photos=rc.sync.probe_all_photos, + ) + + # --- Запись результата в sync_runs --- + persistence.finish_sync_run( + run_id, + status="success", + ids_fetched=result.get("items_collected", 0), + cars_upserted=result.get("cars_synced", 0), + cars_failed=result.get("cars_failed", 0), + images_upserted=0, + ) + + summary = { + "task_id": task_id, + "source": "encar", + "total_available": result.get("total_available", 0), + "items_collected": result.get("items_collected", 0), + "cars_synced": result.get("cars_synced", 0), + "cars_failed": result.get("cars_failed", 0), + "cars_marked_sold": result.get("cars_marked_sold", 0), + } + logger.info( + "encar_sync_listing_task completed: %d synced, %d failed, %d marked sold", + summary["cars_synced"], summary["cars_failed"], summary["cars_marked_sold"], + ) + return {"status": "success", **summary} + + except Exception as exc: + logger.error("encar_sync_listing_task failed: %s", exc, exc_info=True) + try: + if run_id is not None: + persistence.finish_sync_run( + run_id, + status="failed", + ids_fetched=0, + cars_upserted=0, + cars_failed=0, + images_upserted=0, + error_summary=str(exc)[:500], + ) + except Exception: + logger.warning("Failed to record sync_run failure", exc_info=True) + try: + # Retry сам перепланирует задачу — не планируем следующий прогон + reschedule_next = False + raise self.retry(exc=exc) + except MaxRetriesExceededError: + # Retries исчерпаны — запускаем следующий прогон по расписанию + reschedule_next = True + raise + finally: + if heartbeat_stop is not None: + heartbeat_stop.set() + if heartbeat_thread is not None: + heartbeat_thread.join(timeout=max(1.0, min(5.0, lock_ttl / 10))) + if lock_acquired: + _release_lock_if_owner(redis_client, ENCAR_SYNC_LOCK_KEY, owner_token) + if reschedule_next: + try: + interval_minutes = Settings().celery.encar_beat_interval_minutes + countdown = max(60, int(interval_minutes) * 60) + self.apply_async( + kwargs={"car_type": car_type}, + countdown=countdown, + queue="encar", + ) + logger.info( + "Next encar_sync_listing_task scheduled in %d minutes", + countdown // 60, + ) + except Exception: + logger.warning("Failed to schedule next sync run", exc_info=True) + + +@shared_task( + name="encar_scraper.worker.tasks.encar_sync_vehicle_task", + bind=True, + max_retries=2, + default_retry_delay=30, + acks_late=True, +) +def encar_sync_vehicle_task(self, vehicle_url: str, lane: str = "encar"): + """Синхронизация одного авто Encar.""" + persistence = _get_persistence() + persistence.create_tables() + + try: + scraper = EncarScraper(persistence=persistence) + result = scraper.sync_vehicle(vehicle_url, lane=lane) + logger.info("encar_sync_vehicle_task completed: %s", vehicle_url) + return { + "status": "success", + "vehicle_url": vehicle_url, + "origin_id": result.get("origin_id"), + } + except Exception as exc: + logger.error("encar_sync_vehicle_task failed: %s — %s", vehicle_url, exc, exc_info=True) + raise self.retry(exc=exc) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e5dcf8b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +# Encar scraper — простой entrypoint без браузера. +# Просто запускает переданную команду. + +exec "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f267278 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "encar-scraper" +version = "0.1.0" +description = "Encar scraper service with FastAPI, Celery and PostgreSQL" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "alembic>=1.14.0", + "celery>=5.4.0", + "fastapi>=0.115.0", + "psycopg2-binary>=2.9.9", + "pydantic>=2.8.2", + "python-dotenv>=1.0.1", + "redis>=5.2.0", + "sqlalchemy>=2.0.32", + "uvicorn>=0.34.0", + "urllib3>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "httpx2>=0.28.0", + "pytest>=8.3.0", + "pytest-cov>=5.0.0", +] + +[project.scripts] +encar = "encar_scraper.cli:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["encar_scraper*"] + +[tool.pytest.ini_options] +addopts = "-q --disable-warnings" +python_files = ["tests/test_*.py"] +log_cli = false \ No newline at end of file diff --git a/runtime_config.json b/runtime_config.json new file mode 100644 index 0000000..94f6375 --- /dev/null +++ b/runtime_config.json @@ -0,0 +1,105 @@ +{ + "sync": { + "name": null, + "ids_initial_size": null, + "ids_next_size": null, + "ids_max_pages": null, + "condition_check_enabled": false, + "lane": "encar_cars", + "only_new": false, + "limit": null, + "probe_all_photos": true + }, + "filters": { + "price": { + "min": null, + "max": null + }, + "mileage": { + "min": null, + "max": null + }, + "flags": { + "damaged_only": null, + "run_and_drive": null + }, + "brands": [ + "Acura", + "Alfa Romeo", + "Alpina", + "Aston Martin", + "Audi", + "BMW", + "BMW ALPINA", + "Bentley", + "Bugatti", + "Cadillac", + "Caterham", + "Chevrolet", + "Chrysler", + "Cupra", + "Daimler", + "Dodge", + "Ferrari", + "Ford", + "Genesis", + "Honda", + "Hummer", + "Hyundai", + "Infiniti", + "Isuzu", + "Jaguar", + "Jeep", + "Kia", + "Lamborghini", + "Land Rover", + "Lexus", + "Lincoln", + "Lotus", + "Lucid", + "Maserati", + "Maybach", + "Mazda", + "McLaren", + "Mercedes-Benz", + "Mercury", + "Mini", + "Mitsubishi", + "Nissan", + "Oldsmobile", + "Pagani", + "Plymouth", + "Pontiac", + "Porsche", + "Ram", + "Ravon", + "Rivian", + "Rolls-Royce", + "Rolls Royce", + "Rover", + "Saturn", + "Skoda", + "Smart", + "Subaru", + "Suzuki", + "Tesla", + "Toyota", + "Volkswagen", + "Volvo", + "KTM AG", + "Koenigsegg", + "Rimac" + ], + "models": [], + "years": [], + "body_types": [], + "colors": [], + "drives": [], + "gearboxes": [], + "locations": [], + "exclude_brands": [], + "exclude_models": [], + "exclude_years": [], + "exclude_body_types": [] + } +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b33f680 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +import logging + + +def pytest_configure() -> None: + logging.disable(logging.CRITICAL) diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..839324c --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from sqlalchemy import select + +from encar_scraper.core.config import Settings +from encar_scraper.storage.db import PersistenceService +from encar_scraper.storage.models import Car, Image, SyncRun +from encar_scraper.storage.schemas import CarRecord, ImageRecord + + +class TestPersistenceServiceIntegration(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + db_path = Path(self.tmp_dir.name) / "test.sqlite" + + self.settings = Settings() + self.settings.database.url = f"sqlite:///{db_path.as_posix()}" + self.settings.database.echo = False + + self.persistence = PersistenceService(self.settings) + self.persistence.create_tables() + + def tearDown(self) -> None: + self.persistence.engine.dispose() + self.tmp_dir.cleanup() + + @staticmethod + def _record(origin_id: str, *, price: int = 1000) -> CarRecord: + return CarRecord( + parser_id=f"encar:{origin_id}", + brand="Toyota", + model="Camry", + year=2014, + price=price, + origin_url=f"https://www.encar.com/dc/dc_cardetailview.do?carid={origin_id}", + origin_id=origin_id, + slug=f"toyota-camry-{origin_id}", + images=[ + ImageRecord( + fullres_image=f"https://www.encar.com/carpicture01/pic{origin_id}_001.jpg", + preview_image=f"https://www.encar.com/carpicture01/pic{origin_id}_001.jpg", + order_index=0, + ) + ], + ) + + def test_insert_update_and_skip_flow(self) -> None: + first = self._record("777", price=1000) + inserted = self.persistence.upsert_car(first) + self.assertEqual(inserted["action"], "inserted") + self.assertEqual(inserted["images_upserted"], 1) + + same = self._record("777", price=1000) + updated_same = self.persistence.upsert_car(same) + self.assertEqual(updated_same["action"], "updated") + self.assertEqual(updated_same["images_upserted"], 1) + + changed = self._record("777", price=1500) + updated = self.persistence.upsert_car(changed) + self.assertEqual(updated["action"], "updated") + self.assertEqual(updated["images_upserted"], 1) + + with self.persistence.session_scope() as session: + cars = session.execute(select(Car)).scalars().all() + images = session.execute(select(Image)).scalars().all() + + self.assertEqual(len(cars), 1) + self.assertEqual(cars[0].price, 1500) + self.assertEqual(len(images), 1) + + def test_update_replaces_old_images(self) -> None: + first = self._record("888") + self.persistence.upsert_car(first) + + second = self._record("888") + second.images = [ + ImageRecord( + fullres_image="https://www.encar.com/carpicture01/pic888_002.jpg", + preview_image="https://www.encar.com/carpicture01/pic888_002.jpg", + order_index=0, + ) + ] + self.persistence.upsert_car(second) + + with self.persistence.session_scope() as session: + images = session.execute(select(Image)).scalars().all() + + self.assertEqual(len(images), 1) + self.assertIn("pic888_002.jpg", images[0].fullres_image) + + def test_start_sync_run_marks_stale_running_runs_as_failed(self) -> None: + first_run_id = self.persistence.start_sync_run("lane-a") + second_run_id = self.persistence.start_sync_run("lane-b") + + self.assertNotEqual(first_run_id, second_run_id) + + with self.persistence.session_scope() as session: + first = session.get(SyncRun, first_run_id) + second = session.get(SyncRun, second_run_id) + + self.assertEqual(first.status, "failed") + self.assertIsNotNone(first.finished_at) + self.assertEqual(second.status, "running") + + def test_upsert_falls_back_to_origin_url_to_prevent_duplicates(self) -> None: + first = self._record("OLD-ID") + first.origin_url = "https://www.encar.com/dc/dc_cardetailview.do?carid=45089484" + self.persistence.upsert_car(first) + + second = self._record("NEW-ID") + second.origin_url = "https://www.encar.com/dc/dc_cardetailview.do?carid=45089484" + result = self.persistence.upsert_car(second) + + self.assertEqual(result["action"], "updated") + with self.persistence.session_scope() as session: + cars = session.execute(select(Car)).scalars().all() + + self.assertEqual(len(cars), 1) + self.assertEqual(cars[0].origin_id, "NEW-ID") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_encar.py b/tests/test_encar.py new file mode 100644 index 0000000..442bce4 --- /dev/null +++ b/tests/test_encar.py @@ -0,0 +1,148 @@ +import unittest +from unittest.mock import MagicMock + +from encar_scraper.encar import EncarMapper, EncarScraper, ENCAR_DETAIL_URL_TEMPLATE + + +class TestEncarMapper(unittest.TestCase): + def test_map_to_car_record(self): + payload = { + "Id": 41421262, + "Manufacturer": "Hyundai", + "Model": "G90 (RS4)", + "FormYear": "2023", + "Price": 8590, + "Mileage": 37923, + "Photos": [ + {"location": "/carpicture01/pic4141/41412663_001.jpg", "ordering": 1}, + {"location": "/carpicture01/pic4141/41412663_003.jpg", "ordering": 3}, + ], + } + + record = EncarMapper().map_to_car_record( + ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262"), + payload, + ) + + self.assertEqual(record.origin_id, "encar:41421262") + self.assertEqual(record.parser_id, "encar:41421262") + self.assertEqual(record.brand, "Hyundai") + # Скобки удаляются — на оригинальном сайте Encar их нет. + self.assertEqual(record.model, "G90") + self.assertEqual(record.year, 2023) + self.assertEqual(record.price, 85900000) # 8590 만원 × 10000 + self.assertEqual(record.mileage, 37923) + self.assertEqual(record.country, "KR") + self.assertEqual(record.currency, "KRW") + self.assertEqual(len(record.images), 2) + self.assertTrue(record.images[0].fullres_image.endswith("_001.jpg")) + + def test_extract_vehicle_id_from_url(self): + url = ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41421262") + self.assertEqual(EncarMapper()._extract_vehicle_id(url), "41421262") + + def test_map_to_car_record_uses_base_model_without_trim(self): + payload = { + "Id": 41854374, + "Manufacturer": "현대", + "Model": "Genesis", + "Badge": "BH330 Grand", + "BadgeDetail": "Prime팩", + "FormYear": "2011", + "Price": 1200, + "Mileage": 100000, + "Photos": [ + {"location": "/carpicture01/pic4185/41854374_001.jpg", "ordering": 1}, + ], + } + + record = EncarMapper().map_to_car_record( + ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="41854374"), + payload, + ) + + self.assertEqual(record.brand, "Hyundai") + self.assertEqual(record.model, "Genesis") + self.assertEqual(record.origin_id, "encar:41854374") + + def test_map_to_car_record_resolves_numeric_id_from_photos(self): + payload = { + "Id": "car-BOqLLkjkgbpStlVJEJvbRG", + "Manufacturer": "Kia", + "Model": "K3", + "FormYear": "2018", + "Price": 990, + "Mileage": 75000, + "Photos": [ + {"location": "/carpicture01/pic4187/41878507_001.jpg", "ordering": 1}, + ], + } + + record = EncarMapper().map_to_car_record("", payload) + + self.assertEqual(record.origin_id, "encar:41878507") + self.assertEqual(record.parser_id, "encar:41878507") + self.assertIn("carid=41878507", record.origin_url) + + def test_translate_model_keeps_english_text(self): + mapper = EncarMapper() + + self.assertEqual(mapper._translate_model("All New Carnival"), "All New Carnival") + self.assertEqual(mapper._translate_model("New Kia Ray"), "New Kia Ray") + self.assertEqual(mapper._translate_model("K5 Hybrid 3rd gen"), "K5 Hybrid 3rd gen") + + def test_translate_model_handles_korean_fragments(self): + mapper = EncarMapper() + + cases = { + # Скобки и их содержимое отбрасываются как на сайте Encar. + "5시리즈 (G30)": "5 Series", + "S-클래스 W223": "S-Class W223", + "Sonata 디 엣지(DN8)": "Sonata The Edge", + "렉스턴 Sport 칸": "Rexton Sport Khan", + "티볼리 Air": "Tivoli Air", + "코란도 투리스모": "Korando Turismo", + "Kia Carnival 4세대": "Kia Carnival 4th gen", + "Cadillac 에스컬레이드 5세대": "Cadillac Escalade 5th gen", + "K7 프리미어": "K7 Premier", + } + + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(mapper._translate_model(raw), expected) + + def test_translate_model_drops_untranslated_korean(self): + mapper = EncarMapper() + + cases = { + # "레이" (Ray) не должен резать слово "슈팅브레이크" изнутри. + "슈팅브레이크": "", + "The New G70 슈팅브레이크": "The New G70", + "G70 (슈팅브레이크)": "G70", + } + + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(mapper._translate_model(raw), expected) + + +class TestEncarScraper(unittest.TestCase): + def test_collect_listing_limits_results(self): + scraper = EncarScraper() + + scraper._fetch_listing_page = MagicMock(side_effect=[ + {"SearchResults": [{"Id": 1}, {"Id": 2}]}, + ]) + + result = scraper.collect_listing(limit=1, page_size=2) + self.assertEqual(result["items_collected"], 1) + self.assertEqual(result["vehicle_urls"], [ENCAR_DETAIL_URL_TEMPLATE.format(vehicle_id="1")]) + + def test_scrape_vehicle_detail_raises_without_id(self): + scraper = EncarScraper() + with self.assertRaises(ValueError): + scraper.scrape_vehicle_detail("https://www.encar.com/invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_load.py b/tests/test_load.py new file mode 100644 index 0000000..e574867 --- /dev/null +++ b/tests/test_load.py @@ -0,0 +1,417 @@ +""" +Нагрузочные тесты: API endpoints, batch upsert, конкурентный доступ к БД. + +Запуск: + pytest tests/test_load.py -v -s +""" + +from __future__ import annotations + +import concurrent.futures +import time +import random +import string +import unittest +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +from encar_scraper.api.app import create_app +from encar_scraper.core.config import DatabaseConfig, RedisConfig, Settings +from encar_scraper.storage.db import PersistenceService +from encar_scraper.storage.schemas import CarRecord, ImageRecord + + +# ── helpers ────────────────────────────────────────────────────────────── + +def _sqlite_settings() -> Settings: + return Settings( + database=DatabaseConfig(url="sqlite:///test_load.db", echo=False, auto_create_tables=True), + redis=RedisConfig(url="redis://localhost:6379/15"), + ) + + +def _make_car(idx: int, *, images: int = 3) -> CarRecord: + uid = f"load-{idx}-{''.join(random.choices(string.ascii_lowercase, k=4))}" + return CarRecord( + parser_id=uid, + brand=random.choice(["Hyundai", "Kia", "BMW", "Mercedes", "Genesis"]), + model=f"Model-{idx % 50}", + year=random.randint(2010, 2025), + price=random.randint(5_000_000, 80_000_000), + currency="KRW", + mileage=random.randint(0, 300_000), + country="KR", + origin="ENCAR", + origin_url=f"https://encar.com/cars/{uid}", + origin_id=uid, + slug=uid, + selling_type="NA", + body_type="SEDAN", + last_seen_at=datetime.now(timezone.utc), + images=[ + ImageRecord( + fullres_image=f"https://ci.encar.com/photo/{uid}/{j}.jpg", + preview_image=f"https://ci.encar.com/photo/{uid}/{j}_thumb.jpg", + order_index=j, + ) + for j in range(images) + ], + ) + + +# ── DB load tests ─────────────────────────────────────────────────────── + +class TestDatabaseLoad(unittest.TestCase): + """Тесты производительности PersistenceService на SQLite.""" + + @classmethod + def setUpClass(cls): + import os + if os.path.exists("test_load.db"): + os.remove("test_load.db") + cls.settings = _sqlite_settings() + cls.persistence = PersistenceService(cls.settings) + cls.persistence.create_tables() + + @classmethod + def tearDownClass(cls): + cls.persistence.engine.dispose() + import os + if os.path.exists("test_load.db"): + os.remove("test_load.db") + + def test_batch_upsert_500_cars(self): + """Пакетная вставка 500 авто с изображениями — должна пройти за <10 сек.""" + records = [_make_car(i, images=5) for i in range(500)] + t0 = time.perf_counter() + result = self.persistence.upsert_cars_batch(records) + elapsed = time.perf_counter() - t0 + + print(f"\n[batch_upsert_500] inserted={result['inserted']}, " + f"updated={result['updated']}, images={result['images_upserted']}, " + f"time={elapsed:.2f}s") + + self.assertEqual(result["inserted"], 500) + self.assertEqual(result["images_upserted"], 2500) + self.assertLess(elapsed, 10, "Batch upsert 500 cars should complete in <10s") + + def test_batch_upsert_update_existing(self): + """Повторный upsert 200 существующих записей — обновление без дублей.""" + records = [_make_car(i, images=3) for i in range(10000, 10200)] + self.persistence.upsert_cars_batch(records) + + # Обновляем цену и перезаписываем + for r in records: + r.price = 99_999_999 + t0 = time.perf_counter() + result = self.persistence.upsert_cars_batch(records) + elapsed = time.perf_counter() - t0 + + print(f"\n[batch_update_200] updated={result['updated']}, time={elapsed:.2f}s") + self.assertEqual(result["updated"], 200) + self.assertLess(elapsed, 10) + + def test_concurrent_upsert_batches(self): + """3 параллельных потока по 100 upsert-ов — нет deadlock / corruption.""" + def worker(thread_id: int): + batch = [_make_car(thread_id * 10000 + i) for i in range(100)] + return self.persistence.upsert_cars_batch(batch) + + t0 = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + futures = [pool.submit(worker, tid) for tid in range(3)] + results = [f.result() for f in concurrent.futures.as_completed(futures)] + elapsed = time.perf_counter() - t0 + + total_inserted = sum(r["inserted"] for r in results) + print(f"\n[concurrent_upsert] total_inserted={total_inserted}, time={elapsed:.2f}s") + self.assertEqual(total_inserted, 300) + self.assertLess(elapsed, 30) + + def test_single_upsert_throughput(self): + """50 последовательных upsert_car — замер throughput.""" + records = [_make_car(50000 + i, images=2) for i in range(50)] + t0 = time.perf_counter() + for r in records: + self.persistence.upsert_car(r) + elapsed = time.perf_counter() - t0 + rps = 50 / elapsed if elapsed > 0 else 0 + + print(f"\n[single_upsert_50] time={elapsed:.2f}s, rps={rps:.1f}") + self.assertLess(elapsed, 15, "50 individual upserts should complete in <15s") + + def test_mark_sold_large_set(self): + """mark_sold на 300 origin_id — должен быть быстрым.""" + records = [_make_car(60000 + i, images=1) for i in range(300)] + self.persistence.upsert_cars_batch(records) + + active_ids = {r.origin_id for r in records[:150]} + t0 = time.perf_counter() + sold = self.persistence.mark_sold_not_in_listing(active_origin_ids=active_ids, lane="encar") + elapsed = time.perf_counter() - t0 + + print(f"\n[mark_sold_300] marked_sold={sold}, time={elapsed:.2f}s") + self.assertLess(elapsed, 5) + + def test_sync_run_lifecycle(self): + """start → finish sync_run 50 раз — измеряем overhead.""" + t0 = time.perf_counter() + for _ in range(50): + run_id = self.persistence.start_sync_run("encar") + self.persistence.finish_sync_run( + run_id, + status="completed", + ids_fetched=1000, + cars_upserted=950, + cars_failed=50, + images_upserted=15000, + ) + elapsed = time.perf_counter() - t0 + + print(f"\n[sync_run_lifecycle_50] time={elapsed:.2f}s") + self.assertLess(elapsed, 5) + + +# ── API load tests ────────────────────────────────────────────────────── + +class TestAPILoad(unittest.TestCase): + """Нагрузочные тесты на FastAPI endpoints через TestClient.""" + + @classmethod + def setUpClass(cls): + import os + if os.path.exists("test_api_load.db"): + os.remove("test_api_load.db") + + settings = Settings( + database=DatabaseConfig(url="sqlite:///test_api_load.db", echo=False, auto_create_tables=True), + redis=RedisConfig(url="redis://localhost:6379/15"), + ) + app = create_app(settings) + app.state.persistence.create_tables() + + # Предзаполняем БД 200 авто + persistence: PersistenceService = app.state.persistence + persistence.upsert_cars_batch([_make_car(i, images=4) for i in range(200)]) + + cls.app = app + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + import os + cls.client.close() + cls.app.state.persistence.engine.dispose() + if os.path.exists("test_api_load.db"): + os.remove("test_api_load.db") + + def test_health_check_rapid(self): + """100 GET /health подряд — все 200, <5 сек.""" + t0 = time.perf_counter() + for _ in range(100): + r = self.client.get("/health") + self.assertEqual(r.status_code, 200) + elapsed = time.perf_counter() - t0 + + print(f"\n[health_100] time={elapsed:.2f}s, rps={100 / elapsed:.0f}") + self.assertLess(elapsed, 5) + + def test_list_cars_paginated_rapid(self): + """100 GET /api/v1/cars с разными страницами — все 200, <10 сек.""" + t0 = time.perf_counter() + for page in range(1, 101): + r = self.client.get("/api/v1/cars", params={"page": (page % 10) + 1, "per_page": 20}) + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertIn("items", data) + elapsed = time.perf_counter() - t0 + + print(f"\n[list_cars_100] time={elapsed:.2f}s, rps={100 / elapsed:.0f}") + self.assertLess(elapsed, 10) + + def test_list_cars_with_filters(self): + """50 GET /api/v1/cars с brand/year фильтрами.""" + t0 = time.perf_counter() + for i in range(50): + brand = random.choice(["Hyundai", "Kia", "BMW", "Mercedes", "Genesis"]) + r = self.client.get( + "/api/v1/cars", + params={"brand": brand, "year_min": 2015, "year_max": 2023, "per_page": 50}, + ) + self.assertEqual(r.status_code, 200) + elapsed = time.perf_counter() - t0 + + print(f"\n[filtered_cars_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}") + self.assertLess(elapsed, 10) + + def test_get_car_by_id_rapid(self): + """50 GET /api/v1/cars/{id} — все 200.""" + t0 = time.perf_counter() + for car_id in range(1, 51): + r = self.client.get(f"/api/v1/cars/{car_id}") + self.assertIn(r.status_code, (200, 404)) + elapsed = time.perf_counter() - t0 + + print(f"\n[get_car_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}") + self.assertLess(elapsed, 10) + + def test_stats_endpoint_rapid(self): + """50 GET /api/v1/stats — агрегация по всей таблице.""" + t0 = time.perf_counter() + for _ in range(50): + r = self.client.get("/api/v1/stats") + self.assertEqual(r.status_code, 200) + data = r.json() + self.assertGreater(data["total_cars"], 0) + elapsed = time.perf_counter() - t0 + + print(f"\n[stats_50] time={elapsed:.2f}s, rps={50 / elapsed:.0f}") + self.assertLess(elapsed, 10) + + def test_concurrent_api_requests(self): + """20 конкурентных запросов к разным endpoints — нет 500-х.""" + def make_request(idx: int): + if idx % 3 == 0: + return self.client.get("/health") + elif idx % 3 == 1: + return self.client.get("/api/v1/cars", params={"page": 1, "per_page": 10}) + else: + return self.client.get("/api/v1/stats") + + t0 = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool: + futures = [pool.submit(make_request, i) for i in range(20)] + responses = [f.result() for f in concurrent.futures.as_completed(futures)] + elapsed = time.perf_counter() - t0 + + for r in responses: + self.assertNotEqual(r.status_code, 500, f"Got 500: {r.text}") + + print(f"\n[concurrent_api_20] time={elapsed:.2f}s, all_ok={all(r.status_code == 200 for r in responses)}") + self.assertLess(elapsed, 10) + + def test_large_page_size(self): + """GET /api/v1/cars с per_page=100 — проверяем сериализацию больших ответов.""" + t0 = time.perf_counter() + for _ in range(20): + r = self.client.get("/api/v1/cars", params={"page": 1, "per_page": 100}) + self.assertEqual(r.status_code, 200) + data = r.json() + # Каждый car содержит images — проверяем что сериализация прошла + for item in data["items"]: + self.assertIn("images", item) + elapsed = time.perf_counter() - t0 + + print(f"\n[large_page_20] time={elapsed:.2f}s, rps={20 / elapsed:.0f}") + self.assertLess(elapsed, 10) + + +# ── Data integrity under load ─────────────────────────────────────────── + +class TestDataIntegrity(unittest.TestCase): + """Проверка целостности данных при массовых операциях.""" + + @classmethod + def setUpClass(cls): + import os + if os.path.exists("test_integrity.db"): + os.remove("test_integrity.db") + cls.settings = Settings( + database=DatabaseConfig(url="sqlite:///test_integrity.db", echo=False, auto_create_tables=True), + redis=RedisConfig(url="redis://localhost:6379/15"), + ) + cls.persistence = PersistenceService(cls.settings) + cls.persistence.create_tables() + + @classmethod + def tearDownClass(cls): + cls.persistence.engine.dispose() + import os + if os.path.exists("test_integrity.db"): + os.remove("test_integrity.db") + + def test_no_duplicates_after_repeated_upserts(self): + """3 раза upsert одного батча — ровно N уникальных записей в БД.""" + records = [_make_car(90000 + i) for i in range(100)] + + for _ in range(3): + self.persistence.upsert_cars_batch(records) + + from sqlalchemy import select, func + from encar_scraper.storage.models import Car + + with self.persistence.session_scope() as session: + count = session.execute( + select(func.count(Car.id)).where(Car.origin_id.like("load-90%")) + ).scalar() + + self.assertEqual(count, 100, f"Expected 100 unique cars, got {count}") + + def test_images_replaced_not_accumulated(self): + """При upsert с новыми images — старые удаляются, а не копятся.""" + rec = _make_car(80001, images=5) + self.persistence.upsert_car(rec) + + # Обновляем с 2 изображениями + rec.images = [ + ImageRecord(fullres_image="https://new/1.jpg", preview_image="https://new/1t.jpg", order_index=0), + ImageRecord(fullres_image="https://new/2.jpg", preview_image="https://new/2t.jpg", order_index=1), + ] + self.persistence.upsert_car(rec) + + from sqlalchemy import select, func + from encar_scraper.storage.models import Car, Image + + with self.persistence.session_scope() as session: + car = session.execute(select(Car).where(Car.origin_id == rec.origin_id)).scalar_one() + img_count = session.execute( + select(func.count(Image.id)).where(Image.car_id == car.id) + ).scalar() + + self.assertEqual(img_count, 2, f"Expected 2 images after replace, got {img_count}") + + def test_dedup_within_batch(self): + """Батч с дубликатами origin_id — дедупликация работает.""" + base = _make_car(70001) + dupe = _make_car(70001) + dupe.parser_id = base.parser_id + dupe.origin_id = base.origin_id + dupe.origin_url = base.origin_url + dupe.price = 12345 + + records = [base, dupe] + result = self.persistence.upsert_cars_batch(records) + + self.assertEqual(result["inserted"] + result["updated"], 1, + "Dedup should reduce 2 records to 1") + + def test_batch_1000_cars_integrity(self): + """Вставка 1000 авто — проверяем что ВСЕ записи в БД с верными images.""" + records = [_make_car(200000 + i, images=3) for i in range(1000)] + result = self.persistence.upsert_cars_batch(records) + + self.assertEqual(result["inserted"], 1000) + self.assertEqual(result["images_upserted"], 3000) + + from sqlalchemy import select, func + from encar_scraper.storage.models import Car, Image + + with self.persistence.session_scope() as session: + car_count = session.execute( + select(func.count(Car.id)).where(Car.origin_id.like("load-20____-%")) + ).scalar() + img_count = session.execute( + select(func.count(Image.id)).where( + Image.car_id.in_( + select(Car.id).where(Car.origin_id.like("load-20____-%")) + ) + ) + ).scalar() + + self.assertEqual(car_count, 1000) + self.assertEqual(img_count, 3000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a778ad9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import unittest + +from encar_scraper.core.utils import deep_find_key + + +class TestDeepFindKey(unittest.TestCase): + def test_finds_key_in_nested_structure(self) -> None: + payload = { + "root": { + "target": "a", + "nested": [{"target": "b"}, {"x": 1}], + } + } + + found = deep_find_key(payload, {"target"}) + self.assertEqual(found, ["a", "b"]) + + def test_respects_max_depth(self) -> None: + payload = {"l1": {"l2": {"l3": {"target": "value"}}}} + + found_too_shallow = deep_find_key(payload, {"target"}, max_depth=2) + found_enough_depth = deep_find_key(payload, {"target"}, max_depth=8) + + self.assertEqual(found_too_shallow, []) + self.assertEqual(found_enough_depth, ["value"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worker_tasks.py b/tests/test_worker_tasks.py new file mode 100644 index 0000000..eca8f57 --- /dev/null +++ b/tests/test_worker_tasks.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from encar_scraper.worker import tasks + + +class TestWorkerTaskLockHelpers(unittest.TestCase): + def test_acquire_lock_returns_true_on_success(self) -> None: + redis_client = MagicMock() + redis_client.set.return_value = True + + acquired = tasks._acquire_lock(redis_client, "lock:key", "owner-token", 120) + + self.assertTrue(acquired) + redis_client.set.assert_called_once_with("lock:key", "owner-token", nx=True, ex=120) + + def test_refresh_lock_if_owner_extends_ttl(self) -> None: + redis_client = MagicMock() + mock_script = MagicMock(return_value=1) + redis_client.register_script.return_value = mock_script + + refreshed = tasks._refresh_lock_if_owner(redis_client, "lock:key", "owner-token", 120) + + self.assertTrue(refreshed) + redis_client.register_script.assert_called_once() + mock_script.assert_called_once_with(keys=["lock:key"], args=["owner-token", 120]) + + def test_release_lock_if_owner_uses_owner_token(self) -> None: + redis_client = MagicMock() + mock_script = MagicMock(return_value=1) + redis_client.register_script.return_value = mock_script + + tasks._release_lock_if_owner(redis_client, "lock:key", "owner-token") + + redis_client.register_script.assert_called_once() + mock_script.assert_called_once_with(keys=["lock:key"], args=["owner-token"]) + + def test_encar_sync_listing_task_skips_when_lock_not_acquired(self) -> None: + with patch.object(tasks, "_get_persistence") as get_persistence, \ + patch.object(tasks, "_get_redis") as get_redis, \ + patch.object(tasks, "_acquire_lock", return_value=False): + persistence = MagicMock() + get_persistence.return_value = persistence + get_redis.return_value = MagicMock() + + tasks.encar_sync_listing_task.push_request(id="task-123") + try: + result = tasks.encar_sync_listing_task.run() + finally: + tasks.encar_sync_listing_task.pop_request() + + persistence.create_tables.assert_called_once() + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["reason"], "sync_already_running") + + def test_encar_sync_listing_task_releases_owned_lock(self) -> None: + mock_scraper_instance = MagicMock() + mock_scraper_instance.sync_listing.return_value = { + "total_available": 100, + "items_collected": 10, + "cars_synced": 8, + "cars_failed": 2, + } + + with patch.object(tasks, "_get_persistence") as get_persistence, \ + patch.object(tasks, "_get_redis") as get_redis, \ + patch.object(tasks, "_acquire_lock", return_value=True), \ + patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \ + patch.object(tasks, "_release_lock_if_owner") as release_lock, \ + patch("encar_scraper.worker.tasks.EncarScraper", return_value=mock_scraper_instance): + persistence = MagicMock() + get_persistence.return_value = persistence + redis_client = MagicMock() + get_redis.return_value = redis_client + stop_event = MagicMock() + heartbeat_thread = MagicMock() + start_heartbeat.return_value = (stop_event, heartbeat_thread) + + tasks.encar_sync_listing_task.push_request(id="task-123") + try: + with patch.object(tasks.encar_sync_listing_task, "update_state"): + result = tasks.encar_sync_listing_task.run(car_type="all") + finally: + tasks.encar_sync_listing_task.pop_request() + + self.assertEqual(result["status"], "success") + stop_event.set.assert_called_once() + heartbeat_thread.join.assert_called_once() + release_lock.assert_called_once() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8276e2e --- /dev/null +++ b/uv.lock @@ -0,0 +1,968 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "celery" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "encar-scraper" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "celery" }, + { name = "fastapi" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "urllib3" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14.0" }, + { name = "celery", specifier = ">=5.4.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.9" }, + { name = "pydantic", specifier = ">=2.8.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "redis", specifier = ">=5.2.0" }, + { name = "sqlalchemy", specifier = ">=2.0.32" }, + { name = "urllib3", specifier = ">=2.0.0" }, + { name = "uvicorn", specifier = ">=0.34.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" }, + { url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, + { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, + { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, + { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, + { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +]