From d0e589798488ee7ee71b2ca621d80b823de84f2d Mon Sep 17 00:00:00 2001 From: itqop Date: Wed, 5 Nov 2025 01:11:41 +0300 Subject: [PATCH] feat: first --- TZ.md | 306 ++++++++ poetry.lock | 731 ++++++++++++++++++ pyproject.toml | 31 + rest_template.md | 129 ++++ src/dataloader/__init__.py | 11 + src/dataloader/__main__.py | 23 + src/dataloader/api/__init__.py | 38 + src/dataloader/api/metric_router.py | 43 ++ src/dataloader/api/middleware.py | 148 ++++ src/dataloader/api/os_router.py | 38 + src/dataloader/api/schemas.py | 30 + src/dataloader/api/v1/__init__.py | 5 + src/dataloader/api/v1/router.py | 14 + src/dataloader/base.py | 10 + src/dataloader/config.py | 102 +++ src/dataloader/context.py | 62 ++ src/dataloader/logger/__init__.py | 5 + src/dataloader/logger/context_vars.py | 87 +++ src/dataloader/logger/logger.py | 146 ++++ src/dataloader/logger/models.py | 1 + src/dataloader/logger/utils.py | 1 + .../logger/uvicorn_logging_config.py | 63 ++ 22 files changed, 2024 insertions(+) create mode 100644 TZ.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 rest_template.md create mode 100644 src/dataloader/__init__.py create mode 100644 src/dataloader/__main__.py create mode 100644 src/dataloader/api/__init__.py create mode 100644 src/dataloader/api/metric_router.py create mode 100644 src/dataloader/api/middleware.py create mode 100644 src/dataloader/api/os_router.py create mode 100644 src/dataloader/api/schemas.py create mode 100644 src/dataloader/api/v1/__init__.py create mode 100644 src/dataloader/api/v1/router.py create mode 100644 src/dataloader/base.py create mode 100644 src/dataloader/config.py create mode 100644 src/dataloader/context.py create mode 100644 src/dataloader/logger/__init__.py create mode 100644 src/dataloader/logger/context_vars.py create mode 100644 src/dataloader/logger/logger.py create mode 100644 src/dataloader/logger/models.py create mode 100644 src/dataloader/logger/utils.py create mode 100644 src/dataloader/logger/uvicorn_logging_config.py diff --git a/TZ.md b/TZ.md new file mode 100644 index 0000000..180f396 --- /dev/null +++ b/TZ.md @@ -0,0 +1,306 @@ +# ТЗ: `dataloader` (один пакет, async, PG-очередь, LISTEN/NOTIFY) + +## 1) Назначение и рамки + +`dataloader` — сервис постановки и исполнения долгих ETL-задач через одну общую очередь в Postgres. Сервис предоставляет HTTP-ручки для триггера задач, мониторинга статуса и отмены; внутри процесса запускает N асинхронных воркеров, которые конкурируют за задачи через `SELECT … FOR UPDATE SKIP LOCKED`, держат lease/heartbeat, делают идемпотентные записи в целевые БД и корректно обрабатывают повторы. + +Архитектura и инфраструктурные части соответствуют шаблону `rest_template.md`: единый пакет, `os_router.py` с `/health` и `/status`, middleware логирования, структура каталогов и конфиг-классы — **как в шаблоне**. + +--- + +## 2) Архитектура (одно приложение, async) + +* **FastAPI-приложение**: HTTP API v1, инфраструктурные роуты (`/health`, `/status`) из шаблона, middleware и логирование из шаблона. +* **WorkerManager**: на `startup` читает конфиг (`WORKERS_JSON`) и поднимает M асинхронных воркер-циклов (по очередям и уровням параллелизма). На `shutdown` — мягкая остановка. +* **PG Queue**: одна таблица `dl_jobs` на все очереди и сервисы; журнал `dl_job_events`; триггеры LISTEN/NOTIFY для пробуждения воркеров без активного поллинга. + +--- + +## 3) Структура репозитория (один пакет, как в шаблоне) + +``` +dataloader/ +├── src/ +│ └── dataloader/ +│ ├── __main__.py # точка входа FastAPI + запуск WorkerManager (по шаблону) +│ ├── config.py # Pydantic Settings: DSN, тайминги, WORKERS_JSON +│ ├── base.py +│ ├── context.py # AppContext: engine/sessionmaker, DI +│ ├── exceptions.py +│ ├── logger/ # не менять тип и контракты +│ │ ├── __init__.py +│ │ ├── context_vars.py +│ │ ├── logger.py +│ │ ├── models.py +│ │ ├── utils.py +│ │ └── uvicorn_logging_config.py +│ ├── api/ +│ │ ├── __init__.py # регистрация роутов (v1, os_router, metric_router) +│ │ ├── middleware.py +│ │ ├── os_router.py # /health, /status +│ │ ├── metric_router.py +│ │ └── v1/ +│ │ ├── router.py # POST /jobs/trigger, GET /jobs/{id}/status, POST /jobs/{id}/cancel +│ │ ├── schemas.py # pydantic запросы/ответы +│ │ ├── service.py # бизнес-логика +│ │ ├── models.py +│ │ ├── exceptions.py +│ │ └── utils.py +│ ├── storage/ +│ │ ├── db.py # async engine + sessionmaker +│ │ └── repositories.py # SQL-операции по очереди и событиям +│ └── workers/ +│ ├── manager.py # создание asyncio Tasks воркеров по конфигу +│ ├── base.py # общий PG-воркер: claim/lease/heartbeat/retry +│ └── pipelines/ +│ ├── __init__.py +│ └── registry.py # реестр обработчиков по task +├── tests/ +│ └── integration_tests/ +│ ├── conftest.py +│ └── v1_api/ +│ ├── constants.py +│ └── test_service.py +├── pyproject.toml +├── Dockerfile +├── .env +└── .gitignore +``` + +Структура, ролевые файлы и подход соответствуют `rest_template.md`. + +--- + +## 4) DDL очереди (общая для всех сервисов) + +> Таблицы уже созданы и доступны приложению. + +```sql +CREATE TYPE dl_status AS ENUM ('queued','running','succeeded','failed','canceled','lost'); + +CREATE TABLE dl_jobs ( + job_id uuid PRIMARY KEY, + queue text NOT NULL, + task text NOT NULL, + args jsonb NOT NULL DEFAULT '{}'::jsonb, + idempotency_key text UNIQUE, + lock_key text NOT NULL, + partition_key text NOT NULL DEFAULT '', + priority int NOT NULL DEFAULT 100, + available_at timestamptz NOT NULL DEFAULT now(), + status dl_status NOT NULL DEFAULT 'queued', + attempt int NOT NULL DEFAULT 0, + max_attempts int NOT NULL DEFAULT 5, + lease_ttl_sec int NOT NULL DEFAULT 60, + lease_expires_at timestamptz, + heartbeat_at timestamptz, + cancel_requested boolean NOT NULL DEFAULT false, + progress jsonb NOT NULL DEFAULT '{}'::jsonb, + error text, + producer text, + consumer_group text, + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + finished_at timestamptz, + CONSTRAINT dl_jobs_chk_positive CHECK (priority >= 0 AND attempt >= 0 AND max_attempts >= 0 AND lease_ttl_sec > 0) +); + +CREATE INDEX ix_dl_jobs_claim ON dl_jobs(queue, available_at, priority, created_at) + WHERE status = 'queued'; + +CREATE INDEX ix_dl_jobs_running_lease ON dl_jobs(lease_expires_at) + WHERE status = 'running'; + +CREATE INDEX ix_dl_jobs_status_queue ON dl_jobs(status, queue); + +CREATE TABLE dl_job_events ( + event_id bigserial PRIMARY KEY, + job_id uuid NOT NULL REFERENCES dl_jobs(job_id) ON DELETE CASCADE, + queue text NOT NULL, + ts timestamptz NOT NULL DEFAULT now(), + kind text NOT NULL, + payload jsonb +); + +CREATE OR REPLACE FUNCTION notify_job_ready() RETURNS trigger AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + PERFORM pg_notify('dl_jobs', NEW.queue); + RETURN NEW; + ELSIF (TG_OP = 'UPDATE') THEN + IF NEW.status = 'queued' AND NEW.available_at <= now() + AND (OLD.status IS DISTINCT FROM NEW.status OR OLD.available_at IS DISTINCT FROM NEW.available_at) THEN + PERFORM pg_notify('dl_jobs', NEW.queue); + END IF; + RETURN NEW; + END IF; + RETURN NEW; +END $$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS dl_jobs_notify_ins ON dl_jobs; +CREATE TRIGGER dl_jobs_notify_ins +AFTER INSERT ON dl_jobs +FOR EACH ROW EXECUTE FUNCTION notify_job_ready(); + +DROP TRIGGER IF EXISTS dl_jobs_notify_upd ON dl_jobs; +CREATE TRIGGER dl_jobs_notify_upd +AFTER UPDATE OF status, available_at ON dl_jobs +FOR EACH ROW EXECUTE FUNCTION notify_job_ready(); +``` + +--- + +## 5) Контракты API (v1) + +* `POST /api/v1/jobs/trigger` + Вход: `{queue: str, task: str, args?: dict, idempotency_key?: str, lock_key: str, partition_key?: str, priority?: int, available_at?: RFC3339}` + Выход: `{job_id: UUID, status: str}` + Поведение: идемпотентная постановка; триггер LISTEN/NOTIFY срабатывает за счёт триггера в БД. + +* `GET /api/v1/jobs/{job_id}/status` + Выход: `{job_id, status, attempt, started_at?, finished_at?, heartbeat_at?, error?, progress: {}}` + +* `POST /api/v1/jobs/{job_id}/cancel` + Выход: `{… как status …}` + Поведение: устанавливает `cancel_requested = true`. Воркер кооперативно завершает задачу между чанками. + +Инфраструктурные эндпоинты `/health`, `/status`, мидлвар и регистрация роутов — **как в шаблоне**. + +--- + +## 6) Протокол выполнения (воркер) + +1. **Claim** одной задачи: + +```sql +WITH cte AS ( + SELECT job_id + FROM dl_jobs + WHERE status='queued' AND queue=:queue AND available_at <= now() + ORDER BY priority ASC, created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 +) +UPDATE dl_jobs j +SET status='running', + started_at = COALESCE(started_at, now()), + attempt = attempt + 1, + lease_expires_at = now() + make_interval(secs => j.lease_ttl_sec), + heartbeat_at = now() +FROM cte +WHERE j.job_id = cte.job_id +RETURNING j.job_id, j.task, j.args, j.lock_key, j.partition_key, j.lease_ttl_sec; +``` + +Затем `SELECT pg_try_advisory_lock(hashtext(:lock_key))`. Если `false` — `backoff`: + +```sql +UPDATE dl_jobs +SET status='queued', available_at = now() + make_interval(secs => :sec) +WHERE job_id=:jid; +``` + +2. **Heartbeat** раз в `DL_HEARTBEAT_SEC`: + +```sql +UPDATE dl_jobs +SET heartbeat_at = now(), + lease_expires_at = now() + make_interval(secs => :ttl) +WHERE job_id = :jid AND status='running'; +``` + +3. **Завершение**: + +* Успех: + +```sql +UPDATE dl_jobs +SET status='succeeded', finished_at=now(), lease_expires_at=NULL +WHERE job_id=:jid; +``` + +* Ошибка/ретрай: + +```sql +UPDATE dl_jobs +SET status = CASE WHEN attempt < max_attempts THEN 'queued' ELSE 'failed' END, + available_at = CASE WHEN attempt < max_attempts THEN now() + make_interval(secs => 30 * attempt) ELSE now() END, + error = :err, + lease_expires_at = NULL, + finished_at = CASE WHEN attempt >= max_attempts THEN now() ELSE NULL END +WHERE job_id=:jid; +``` + +Всегда выставлять/снимать advisory-lock на `lock_key`. + +4. **Отмена**: воркер проверяет `cancel_requested` между чанками; при `true` завершает пайплайн (обычно как `canceled` либо как `failed` без ретраев — политика проекта). + +5. **Reaper** (фон у приложения): раз в `DL_REAPER_PERIOD_SEC` возвращает «потерянные» задачи в очередь. + +```sql +UPDATE dl_jobs +SET status='queued', available_at=now(), lease_expires_at=NULL +WHERE status='running' + AND lease_expires_at IS NOT NULL + AND lease_expires_at < now() +RETURNING job_id; +``` + +--- + +## 7) Оптимизация и SLA + +* Claim — O(log N) благодаря частичному индексу `ix_dl_jobs_claim`. +* Reaper — O(log N) по индексу `ix_dl_jobs_running_lease`. +* `/health` — без БД; время ответа ≤ 20 мс. `/jobs/*` — не держат долгих транзакций. +* Гарантия доставки: **at-least-once**; операции записи в целевые таблицы — идемпотентны (реализуется в конкретных пайплайнах). +* Конкуренция: один `lock_key` одновременно исполняется одним воркером; параллелизм достигается независимыми `partition_key`. + +--- + +## 8) Конфигурация (ENV) + +* `DL_DB_DSN` — DSN Postgres (async). +* `WORKERS_JSON` — JSON-список конфигураций воркеров, напр.: `[{"queue":"load.cbr","concurrency":2},{"queue":"load.sgx","concurrency":1}]`. +* `DL_HEARTBEAT_SEC` (деф. 10), `DL_DEFAULT_LEASE_TTL_SEC` (деф. 60), `DL_REAPER_PERIOD_SEC` (деф. 10), `DL_CLAIM_BACKOFF_SEC` (деф. 15). +* Логирование, middleware, `uvicorn_logging_config` — **из шаблона без изменения контрактов**. + +--- + +## 9) Эксплуатация и деплой + +* Один контейнер, один Pod, **несколько async-воркеров** внутри процесса (через `WorkerManager`). +* Масштабирование — количеством реплик Deployment: очередь в БД, `FOR UPDATE SKIP LOCKED` и advisory-lock обеспечат корректность в гонке. +* Пробы: `readiness/liveness` на `/health` из `os_router.py`. +* Завершение: на SIGTERM — остановить reaper, подать сигнал воркерам для мягкой остановки, дождаться тасков с таймаутом. + +--- + +## 10) Безопасность, аудит, наблюдаемость + +* Структурные логи через `logger/*` шаблона; маскирование чувствительных полей — как в `logger/utils.py`. +* Журнал жизненного цикла в `dl_job_events` (queued/picked/heartbeat/requeue/done/failed/canceled). +* Метрики (BETA) — через `metric_router.py` из шаблона при необходимости. + +--- + +## 11) Тест-план + +* Интеграционные тесты `v1`: постановка → статус → отмена. +* E2E: постановка → claim мок-воркером → heartbeat → done → статус `succeeded`. +* Конкуренция: два воркера на один `lock_key` → один backoff, один исполняет. +* Reaper: просроченный lease → возврат в `queued`. + +--- + +## 12) TODO + +* [ ] `context.py`: инициализация engine/sessionmaker, AppContext (как в шаблоне). +* [ ] `api/v1/router.py`: `trigger`, `status`, `cancel`. +* [ ] `api/v1/service.py`: бизнес-логика поверх репозитория. +* [ ] `storage/repositories.py`: SQL для create_or_get, get_status, cancel, requeue_lost. +* [ ] `workers/base.py`: claim/heartbeat/finish/retry/cancel/advisory-lock. +* [ ] `workers/manager.py`: парсинг `WORKERS_JSON`, создание тасков, graceful shutdown. +* [ ] Тесты `tests/integration_tests/v1_api/test_service.py` по стилю шаблона. +* [ ] Документация `.env` и примеры `WORKERS_JSON`. +* [x] **БД-таблицы уже созданы** (DDL применён). diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..b24df41 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,731 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "annotated-doc" +version = "0.0.3" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580"}, + {file = "annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "asyncpg" +version = "0.30.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "asyncpg-0.30.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e"}, + {file = "asyncpg-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f"}, + {file = "asyncpg-0.30.0-cp310-cp310-win32.whl", hash = "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf"}, + {file = "asyncpg-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454"}, + {file = "asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d"}, + {file = "asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af"}, + {file = "asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e"}, + {file = "asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba"}, + {file = "asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590"}, + {file = "asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:29ff1fc8b5bf724273782ff8b4f57b0f8220a1b2324184846b39d1ab4122031d"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64e899bce0600871b55368b8483e5e3e7f1860c9482e7f12e0a771e747988168"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b290f4726a887f75dcd1b3006f484252db37602313f806e9ffc4e5996cfe5cb"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f86b0e2cd3f1249d6fe6fd6cfe0cd4538ba994e2d8249c0491925629b9104d0f"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:393af4e3214c8fa4c7b86da6364384c0d1b3298d45803375572f415b6f673f38"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fd4406d09208d5b4a14db9a9dbb311b6d7aeeab57bded7ed2f8ea41aeef39b34"}, + {file = "asyncpg-0.30.0-cp38-cp38-win32.whl", hash = "sha256:0b448f0150e1c3b96cb0438a0d0aa4871f1472e58de14a3ec320dbb2798fb0d4"}, + {file = "asyncpg-0.30.0-cp38-cp38-win_amd64.whl", hash = "sha256:f23b836dd90bea21104f69547923a02b167d999ce053f3d502081acea2fba15b"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f4e83f067b35ab5e6371f8a4c93296e0439857b4569850b178a01385e82e9ad"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5df69d55add4efcd25ea2a3b02025b669a285b767bfbf06e356d68dbce4234ff"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3479a0d9a852c7c84e822c073622baca862d1217b10a02dd57ee4a7a081f708"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26683d3b9a62836fad771a18ecf4659a30f348a561279d6227dab96182f46144"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1b982daf2441a0ed314bd10817f1606f1c28b1136abd9e4f11335358c2c631cb"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1c06a3a50d014b303e5f6fc1e5f95eb28d2cee89cf58384b700da621e5d5e547"}, + {file = "asyncpg-0.30.0-cp39-cp39-win32.whl", hash = "sha256:1b11a555a198b08f5c4baa8f8231c74a366d190755aa4f99aacec5970afe929a"}, + {file = "asyncpg-0.30.0-cp39-cp39-win_amd64.whl", hash = "sha256:8b684a3c858a83cd876f05958823b68e8d14ec01bb0c0d14a6704c5bf9711773"}, + {file = "asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851"}, +] + +[package.extras] +docs = ["Sphinx (>=8.1.3,<8.2.0)", "sphinx-rtd-theme (>=1.2.2)"] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] +test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0,<24.2.0)", "gssapi ; platform_system == \"Linux\"", "k5test ; platform_system == \"Linux\"", "mypy (>=1.8.0,<1.9.0)", "sspilib ; platform_system == \"Windows\"", "uvloop (>=0.15.3) ; platform_system != \"Windows\" and python_version < \"3.14.0\""] + +[[package]] +name = "certifi" +version = "2025.10.5" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, +] + +[[package]] +name = "click" +version = "8.3.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "fastapi" +version = "0.121.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106"}, + {file = "fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.50.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "pydantic" +version = "2.12.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, + {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, + {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.44" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"}, + {file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"}, + {file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.49.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f"}, + {file = "starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uvicorn" +version = "0.23.2" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.23.2-py3-none-any.whl", hash = "sha256:1f9be6558f01239d4fdf22ef8126c39cb1ad0addf76c40e760549d2c2f43ab53"}, + {file = "uvicorn-0.23.2.tar.gz", hash = "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "20a5741e4fc170b6e9c9e47034bde99a2a1a34fdd85e4436699c06fb01b715fc" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e07c744 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[tool.poetry] +name = "dataloader" +version = "0.1.0" +description = "Dataloader for something" +authors = ["Your Name "] +readme = "README.md" +packages = [{include = "dataloader", from = "src"}] + +[tool.poetry.dependencies] +python = "^3.11" +uvicorn = "^0.23.2" +fastapi = "^0.121.0" +pydantic = "^2.12.3" +pydantic-settings = "^2.11.0" +asyncpg = "^0.30.0" +sqlalchemy = "^2.0.0" +httpx = "^0.28.0" +pytz = "^2025.1" +loguru = "^0.7.2" + + + + +[tool.poetry.group.dev.dependencies] + +[tool.poetry.scripts] +dataloader = "dataloader.__main__:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/rest_template.md b/rest_template.md new file mode 100644 index 0000000..4bbdabb --- /dev/null +++ b/rest_template.md @@ -0,0 +1,129 @@ +```bash +aigw-project/ +└── src/ + └── tenera_etl/ # следует использовать своё название + ├── api/ + │ ├── v1/ + │ │ ├── __init__.py + │ │ ├── router.py + │ │ ├── schemas.py # pydantic models + │ │ ├── service.py + │ │ ├── models.py # db models, data classes, named tuples + │ │ ├── exceptions.py + │ │ └── utils.py + │ ├── __init__.py + │ ├── middleware.py + │ ├── os_router.py # роутер для health и status endpoints + │ ├── metric_router.py # !BETA! роутер для оценки сервиса + │ └── schemas.py # валидация endpoint-ов из os_router и metric_router + ├── interfaces/ # интерфейсы для подключения к внешним AC + ├── logger/ + │ ├── __init__.py + │ ├── context_vars.py # управление контекстом запросов для логирования + │ ├── logger.py # реализация логгера + │ ├── models.py # модели логов, метрик, событий аудита + │ ├── utils.py # функции + маскирование args + │ └── uvicorn_logging_config.py # конфигурация логирования uvicorn + ├── __main__.py + ├── base.py + ├── config.py # global configs + ├── exceptions.py # global exceptions + └── context.py + +├── tests/ +│ └── integration_tests/ +│ ├── fixtures/ +│ │ └── fixture_*.py +│ ├── conftest.py +│ ├── gigachat_interface/ # тесты интерфейсов (на GigaController) +│ │ └── test_gptchat_*.py +│ └── v1_api/ +│ ├── constants.py +│ ├── test_service.py # тесты бизнес-логики +│ └── test_service_*.py + +├── app.sh # запуск в OpenShift, содержит tool.poetry.scripts +├── Dockerfile # основной Dockerfile +├── Dockerfile-k8s # локальный Docker +├── pyproject.toml +├── poetry.lock +├── .env # переменные окружения +└── .gitignore +``` + + +## 📁 Назначение ключевых файлов + +### `src/tenera_etl/` + +| Файл | Назначение | +|--------------------------|------------| +| `__main__.py` | Точка входа. Запускает FastAPI-приложение. | +| `config.py` | Загрузка и обработка переменных окружения. | +| `base.py` | Базовые классы и типы, переиспользуемые в проекте. | +| `context.py` | Реализация паттерна `AppContext` — единая точка доступа к зависимостям. | + +--- + +### `src/tenera_etl/logger/` + +| Файл | Назначение | +|------------------------------|------------| +| `context_vars.py` | Контекстные переменные для логирования (`Request-ID` и др.). ⚠️ Не редактировать.| +| `logger.py` | Основной логгер приложения. ⚠️ Не редактировать. | +| `models.py` | Модели логов, метрик, аудита. ⚠️ Не редактировать. | +| `utils.py` | Утилиты логгера, включая маскирование данных. | +| `uvicorn_logging_config.py` | Конфигурация логгирования `uvicorn`. | + +--- + +### `src/tenera_etl/api/` + +| Файл | Назначение | +|--------------------|------------| +| `__init__.py` | Конфигуратор FastAPI — регистрация версий и роутов. | +| `os_router.py` | Инфраструктурные endpoint'ы (`/health`, `/status`). ⚠️ Не редактировать. | +| `metric_router.py` | Метрики (BETA). ⚠️ Не редактировать. | +| `schemas.py` | Схемы (Pydantic) для `os_router` и `metric_router`. ⚠️ Не редактировать. | +| `middleware.py` | Мидлвар для логирования входящих и исходящих запросов. ⚠️ Не редактировать. | + +--- + +### `src/tenera_etl/api/v1/` + +| Файл | Назначение | +|------------------|------------| +| `router.py` | Основные endpoint'ы бизнес-логики (`v1`). | +| `schemas.py` | Pydantic-схемы запросов и ответов. | +| `models.py` | Модели данных (DB, классы, namedtuples). | +| `service.py` | Реализация бизнес-логики. | +| `utils.py` | Утилиты и вспомогательные функции. | +| `exceptions.py` | Исключения (`RestNotFound`, `InvalidUserData`, и др.). | + +--- + +### `src/tenera_etl/interfaces/` + +| Файл / Папка | Назначение | +|--------------|------------| +| *всё содержимое* | Интерфейсы взаимодействия с внешними системами (АС и пр.). | + + +Пример логирования в коде: + +```python +from tenera_etl.logger import logger + +logger.info("End processing user registration request") +``` +⚠️ Не передавайте в logger.info(...) ничего, кроме строки — она будет записана в поле message. + +Маскирование чувствительных данных + +В файле logger/utils.py реализовано маскирование: + + все поля, содержащие ключевые слова вроде password, token, secret, будут скрыты; + + работает автоматически, но вы можете конфигурировать список слов и правила. + +Перед добавлением кастомной маскировки — ознакомьтесь с документацией, чтобы избежать утечки данных. \ No newline at end of file diff --git a/src/dataloader/__init__.py b/src/dataloader/__init__.py new file mode 100644 index 0000000..b64b871 --- /dev/null +++ b/src/dataloader/__init__.py @@ -0,0 +1,11 @@ +"""dataloader package exports. + +Собирает верхнеуровневые сабмодули для удобных импортов. +""" + +from . import api + + +__all__ = [ + "api", +] diff --git a/src/dataloader/__main__.py b/src/dataloader/__main__.py new file mode 100644 index 0000000..fae0177 --- /dev/null +++ b/src/dataloader/__main__.py @@ -0,0 +1,23 @@ +import uvicorn + + +from dataloader.api import app_main +from dataloader.config import APP_CONFIG +from dataloader.logger.uvicorn_logging_config import LOGGING_CONFIG, setup_uvicorn_logging + + +def main() -> None: + # Инициализируем логирование uvicorn перед запуском + setup_uvicorn_logging() + + uvicorn.run( + app_main, + host=APP_CONFIG.app.app_host, + port=APP_CONFIG.app.app_port, + access_log=False, + log_config=LOGGING_CONFIG, + ) + + +if __name__ == "__main__": + main() diff --git a/src/dataloader/api/__init__.py b/src/dataloader/api/__init__.py new file mode 100644 index 0000000..b027fcd --- /dev/null +++ b/src/dataloader/api/__init__.py @@ -0,0 +1,38 @@ +from collections.abc import AsyncGenerator +import contextlib +import typing as tp + +from fastapi import FastAPI + +from .metric_router import router as metric_router +from .middleware import log_requests +from .os_router import router as service_router +from .v1 import router as v1_router + + +@contextlib.asynccontextmanager +async def lifespan(app: tp.Any) -> AsyncGenerator[None, None]: + from dataloader.context import APP_CTX + + await APP_CTX.on_startup() + yield + await APP_CTX.on_shutdown() + + +app_main = FastAPI(title="Data Gateway", lifespan=lifespan) + +app_main.middleware("http")(log_requests) + +app_main.include_router( + service_router, tags=["Openshift dataloader routes"] +) +app_main.include_router( + metric_router, tags=["Like/dislike metric dataloader routes"] +) +app_main.include_router( + v1_router, prefix="/api/v1", tags=["dataloader"] +) + +__all__ = [ + "app_main", +] diff --git a/src/dataloader/api/metric_router.py b/src/dataloader/api/metric_router.py new file mode 100644 index 0000000..c9fa14a --- /dev/null +++ b/src/dataloader/api/metric_router.py @@ -0,0 +1,43 @@ +""" 🚨 НЕ РЕДАКТИРОВАТЬ !!!!!! +""" + +import uuid + +from fastapi import APIRouter, Header, status +from dataloader.context import APP_CTX +from . import schemas + +router = APIRouter() +logger = APP_CTX.get_logger() + + +@router.get( + "/like", + status_code=status.HTTP_200_OK, + response_model=schemas.RateResponse, +) +async def like( + # pylint: disable=C0103,W0613 + header_Request_Id: str = Header(uuid.uuid4(), alias="Request-Id") +) -> dict[str, str]: + logger.metric( + metric_name="dataloader_likes_total", + metric_value=1, + ) + return {"rating_result": "like recorded"} + + +@router.get( + "/dislike", + status_code=status.HTTP_200_OK, + response_model=schemas.RateResponse, +) +async def dislike( + # pylint: disable=C0103,W0613 + header_Request_Id: str = Header(uuid.uuid4(), alias="Request-Id") +) -> dict[str, str]: + logger.metric( + metric_name="dataloader_dislikes_total", + metric_value=1, + ) + return {"rating_result": "dislike recorded"} diff --git a/src/dataloader/api/middleware.py b/src/dataloader/api/middleware.py new file mode 100644 index 0000000..f72358c --- /dev/null +++ b/src/dataloader/api/middleware.py @@ -0,0 +1,148 @@ +import json +import time +from datetime import datetime + +from fastapi import Request, status +from starlette.concurrency import iterate_in_threadpool + +from dataloader.context import APP_CTX + +NON_LOGGED_ENDPOINTS = ( + "/like", + "/dislike", + "/health", + "/openapi.json", + "/docs", +) + +HEADERS_WHITE_LIST_TO_LOG = ( + "Request-Id", + "Request-Time", + "System-Id", + "GateWay-Session-Id", + "Client-Id", +) + + +def _get_decoded_body(raw_body: bytes, message_type: str, logger) -> dict: + decoded_body = {} + try: + decoded_body = json.loads(raw_body.decode()) + except (json.JSONDecodeError, UnicodeDecodeError): + logger.warning(f"{message_type} body is not json") + return decoded_body + + +async def log_requests(request: Request, call_next) -> any: + start_time = time.time() + logger = APP_CTX.get_logger() + request_path = request.url.path + + allowed_headers_to_log = ((k, request.headers.get(k)) for k in HEADERS_WHITE_LIST_TO_LOG) + headers_to_log = {header_name: header_value for header_name, header_value in allowed_headers_to_log if header_value} + + APP_CTX.get_context_vars_container().set_context_vars( + request_id=headers_to_log.get("Request-Id", ""), + request_time=headers_to_log.get("Request-Time", ""), + system_id=headers_to_log.get("System-Id", ""), + gw_session_id=headers_to_log.get("GateWay-Session-Id", ""), + ) + + if request_path in NON_LOGGED_ENDPOINTS: + response = await call_next(request) + logger.debug(f"Processed request for {request_path} with code {response.status_code}") + elif headers_to_log.get("Request-Id", None): + raw_request_body = await request.body() + request_body_decoded = _get_decoded_body(raw_request_body, "request", logger) + + logger.info( + f"Incoming {request.method}-request for {request_path}", + args={ + "headers": headers_to_log, + "message": request_body_decoded, + }, + message_type="request", + path=request_path, + ) + + client_id = headers_to_log.get("Client-Id", None) + if client_id: + logger.metric( + metric_name=f"dataloader_user_{client_id}", + metric_value=1, + ) + logger.metric( + metric_name="dataloader_requests_total", + metric_value=1, + ) + logger.audit( + event_name="BusinessRequestReceived", + event_params=json.dumps( + request_body_decoded, + ensure_ascii=False, + ) + ) + + response = await call_next(request) + + response_body = [chunk async for chunk in response.body_iterator] + response.body_iterator = iterate_in_threadpool(iter(response_body)) + + headers_to_log["Response-Time"] = datetime.now(APP_CTX.get_pytz_timezone()).isoformat() + for header in headers_to_log: + response.headers[header] = headers_to_log[header] + + response_body_extracted = response_body[0] if len(response_body) > 0 else b"" + decoded_response_body = _get_decoded_body(response_body_extracted, "response", logger) + + logger.info( + "Outgoing response to client system", + args={ + "headers": headers_to_log, + "message": decoded_response_body, + }, + message_type="response", + path=request_path, + ) + + logger.metric( + metric_name="dataloader_responses_total", + metric_value=1, + ) + + logger.audit( + event_name="BusinessRequestFinished", + event_params=json.dumps( + decoded_response_body, + ensure_ascii=False, + ) + ) + + processing_time_ms = int(round((time.time() - start_time), 3) * 1000) + logger.info(f"Request processing time for {request_path}: {processing_time_ms} ms") + logger.metric( + metric_name="dataloader_process_duration_ms", + metric_value=processing_time_ms, + ) + + if response.status_code < status.HTTP_400_BAD_REQUEST: + logger.metric( + metric_name="dataloader_request_status_success_total", + metric_value=1, + ) + else: + logger.metric( + metric_name="dataloader_request_status_failure_total", + metric_value=1, + ) + else: + logger.info(f"Incoming {request.method}-request with no id for {request_path}") + response = await call_next(request) + logger.info(f"Request with no id for {request_path} processing time: {time.time() - start_time:.3f} s") + + return response + + +__all__ = [ + "log_requests", +] diff --git a/src/dataloader/api/os_router.py b/src/dataloader/api/os_router.py new file mode 100644 index 0000000..1dc9210 --- /dev/null +++ b/src/dataloader/api/os_router.py @@ -0,0 +1,38 @@ +# Инфраструктурные endpoint'ы (/health, /status) +""" 🚨 НЕ РЕДАКТИРОВАТЬ !!!!!! +""" + +from importlib.metadata import distribution + +from fastapi import APIRouter, status + +from . import schemas + +router = APIRouter() + + +@router.get( + "/health", + status_code=status.HTTP_200_OK, + response_model=schemas.HealthResponse, +) +async def health() -> dict[str, str]: + return { + "health_status": "running", + } + + +@router.get( + "/info", + status_code=status.HTTP_200_OK, + response_model=schemas.InfoResponse, +) +async def info() -> schemas.InfoResponse: + dist = distribution("aigw-rest-service") + + return schemas.InfoResponse( + name=str(dist.metadata["Name"]), + description=str(dist.metadata["Summary"]), + type="REST API", + version=str(dist.version), + ) diff --git a/src/dataloader/api/schemas.py b/src/dataloader/api/schemas.py new file mode 100644 index 0000000..6c8c467 --- /dev/null +++ b/src/dataloader/api/schemas.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field + +class HealthResponse(BaseModel): + """Ответ для ручки /health""" + status: str = Field(default="running", description="Service health check", max_length=7) + + class Config: + json_schema_extra = {"example": {"status": "running"}} + + +class InfoResponse(BaseModel): + """Ответ для ручки /info""" + name: str = Field(description="Service name", max_length=50) + description: str = Field(description="Service description", max_length=200) + type: str = Field(default="REST API", description="Service type", max_length=20) + version: str = Field(description="Service version", max_length=20, pattern=r"^\d+\.\d+\.\d+") + + class Config: + json_schema_extra = { + "example": { + "name": "rest-template", + "description": "Python 'AI gateway' template for developing REST microservices", + "type": "REST API", + "version": "0.1.0" + } + } + + +class RateResponse(BaseModel): + rating_result: str = Field(description="Rating that was recorded", max_length=50) diff --git a/src/dataloader/api/v1/__init__.py b/src/dataloader/api/v1/__init__.py new file mode 100644 index 0000000..9ac8ad0 --- /dev/null +++ b/src/dataloader/api/v1/__init__.py @@ -0,0 +1,5 @@ +from .router import router + +__all__ = [ + "router", +] diff --git a/src/dataloader/api/v1/router.py b/src/dataloader/api/v1/router.py new file mode 100644 index 0000000..d083059 --- /dev/null +++ b/src/dataloader/api/v1/router.py @@ -0,0 +1,14 @@ +"""Агрегатор v1-роутов. + +Экспортирует готовый `router`, собранный из модульных роутеров в пакете `routes`. +Оставлен как тонкий слой для обратной совместимости импортов `from dataloader.api.v1 import router`. +""" + +from fastapi import APIRouter + + +router = APIRouter() + + + +__all__ = ["router"] diff --git a/src/dataloader/base.py b/src/dataloader/base.py new file mode 100644 index 0000000..082c7ba --- /dev/null +++ b/src/dataloader/base.py @@ -0,0 +1,10 @@ +from typing import Any + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs) -> Any: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/src/dataloader/config.py b/src/dataloader/config.py new file mode 100644 index 0000000..ec5e2aa --- /dev/null +++ b/src/dataloader/config.py @@ -0,0 +1,102 @@ +import os +from logging import DEBUG, INFO + +from dotenv import load_dotenv +from pydantic import Field +from pydantic_settings import BaseSettings + +load_dotenv() + + +class BaseAppSettings(BaseSettings): + """ + Базовый класс для настроек. + """ + local: bool = Field(validation_alias="LOCAL", default=False) + debug: bool = Field(validation_alias="DEBUG", default=False) + + @property + def protocol(self) -> str: + return "https" if self.local else "http" + + +class AppSettings(BaseAppSettings): + """ + Настройки приложения. + """ + app_host: str = Field(validation_alias="APP_HOST", default="0.0.0.0") + app_port: int = Field(validation_alias="APP_PORT", default=8081) + kube_net_name: str = Field(validation_alias="PROJECT_NAME", default="AIGATEWAY") + timezone: str = Field(validation_alias="TIMEZONE", default="Europe/Moscow") + + +class LogSettings(BaseAppSettings): + """ + Настройки логирования. + """ + private_log_file_path: str = Field(validation_alias="LOG_PATH", default=os.getcwd()) + private_log_file_name: str = Field(validation_alias="LOG_FILE_NAME", default="app.log") + log_rotation: str = Field(validation_alias="LOG_ROTATION", default="10 MB") + private_metric_file_path: str = Field(validation_alias="METRIC_PATH", default=os.getcwd()) + private_metric_file_name: str = Field(validation_alias="METRIC_FILE_NAME", default="app-metric.log") + private_audit_file_path: str = Field(validation_alias="AUDIT_LOG_PATH", default=os.getcwd()) + private_audit_file_name: str = Field(validation_alias="AUDIT_LOG_FILE_NAME", default="events.log") + audit_host_ip: str = Field(validation_alias="HOST_IP", default="127.0.0.1") + audit_host_uid: str = Field(validation_alias="HOST_UID", default="63b6dcee-170b-49bf-a65c-3ec967398ccd") + + @staticmethod + def get_file_abs_path(path_name: str, file_name: str) -> str: + return os.path.join(path_name.strip("/"), file_name.lstrip("/")).strip() + + @property + def log_file_abs_path(self) -> str: + return self.get_file_abs_path(self.private_log_file_path, self.private_log_file_name) + + @property + def metric_file_abs_path(self) -> str: + return self.get_file_abs_path(self.private_metric_file_path, self.private_metric_file_name) + + @property + def audit_file_abs_path(self) -> str: + return self.get_file_abs_path(self.private_audit_file_path, self.private_audit_file_name) + + @property + def log_lvl(self) -> int: + return DEBUG if self.debug else INFO + + +class PGSettings(BaseSettings): + host: str = Field(validation_alias="PG_HOST", default="localhost") + port: int = Field(validation_alias="PG_PORT", default=5432) + user: str = Field(validation_alias="PG_USER", default="postgres") + password: str = Field(validation_alias="PG_PASSWORD", default="") + database: str = Field(validation_alias="PG_DATABASE", default="postgres") + schema_: str = Field(validation_alias="PG_SCHEMA", default="public") + use_pool: bool = Field(validation_alias="PG_USE_POOL", default=True) + pool_size: int = Field(validation_alias="PG_POOL_SIZE", default=5) + max_overflow: int = Field(validation_alias="PG_MAX_OVERFLOW", default=10) + pool_recycle: int = Field(validation_alias="PG_POOL_RECYCLE", default=1800) + connect_timeout: int = Field(validation_alias="PG_CONNECT_TIMEOUT", default=10) + command_timeout: int = Field(validation_alias="PG_COMMAND_TIMEOUT", default=60) + + @property + def url(self) -> str: + """Автоматически генерируется SQLAlchemy URL для подключения""" + return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" + + +class Secrets: + """ + Класс, агрегирующий все настройки приложения. + """ + app: AppSettings = AppSettings() + log: LogSettings = LogSettings() + pg: PGSettings = PGSettings() + + +APP_CONFIG = Secrets() + +__all__ = [ + "Secrets", + "APP_CONFIG", +] diff --git a/src/dataloader/context.py b/src/dataloader/context.py new file mode 100644 index 0000000..c120b95 --- /dev/null +++ b/src/dataloader/context.py @@ -0,0 +1,62 @@ +# Реализация паттерна AppContext — единая точка доступа к зависимостям +from dataloader.base import Singleton +import typing +from dataloader.config import APP_CONFIG, Secrets +from dataloader.logger import ContextVarsContainer, LoggerConfigurator + + +import pytz + + + + +class AppContext(metaclass=Singleton): + @property + def logger(self) -> "typing.Any": + return self._logger_manager.async_logger + + + def __init__(self, secrets: Secrets) -> None: + self.timezone = pytz.timezone(secrets.app.timezone) + self.context_vars_container = ContextVarsContainer() + self._logger_manager = LoggerConfigurator( + log_lvl=secrets.log.log_lvl, + log_file_path=secrets.log.log_file_abs_path, + metric_file_path=secrets.log.metric_file_abs_path, + audit_file_path=secrets.log.audit_file_abs_path, + audit_host_ip=secrets.log.audit_host_ip, + audit_host_uid=secrets.log.audit_host_uid, + context_vars_container=self.context_vars_container, + timezone=self.timezone, + ) + self.pg = secrets.pg + self.logger.info("App context initialized.") + + + def get_logger(self) -> "typing.Any": + return self.logger + + + def get_context_vars_container(self) -> ContextVarsContainer: + return self.context_vars_container + + + def get_pytz_timezone(self): + return self.timezone + + + async def on_startup(self) -> None: + self.logger.info("Application is starting up.") + self.logger.info("All connections checked. Application is up and ready.") + + + async def on_shutdown(self) -> None: + self.logger.info("Application is shutting down.") + self._logger_manager.remove_logger_handlers() + + + +APP_CTX = AppContext(APP_CONFIG) + + +__all__ = ["APP_CTX"] \ No newline at end of file diff --git a/src/dataloader/logger/__init__.py b/src/dataloader/logger/__init__.py new file mode 100644 index 0000000..9d0c9d9 --- /dev/null +++ b/src/dataloader/logger/__init__.py @@ -0,0 +1,5 @@ +# logger package +from .context_vars import ContextVarsContainer +from .logger import LoggerConfigurator + +__all__ = ["ContextVarsContainer", "LoggerConfigurator"] diff --git a/src/dataloader/logger/context_vars.py b/src/dataloader/logger/context_vars.py new file mode 100644 index 0000000..91d98da --- /dev/null +++ b/src/dataloader/logger/context_vars.py @@ -0,0 +1,87 @@ +# Управление контекстом запросов для логирования +import uuid +from contextvars import ContextVar +from typing import Final + + +REQUEST_ID_CTX_VAR: Final[ContextVar[str]] = ContextVar("request_id", default="") +DEVICE_ID_CTX_VAR: Final[ContextVar[str]] = ContextVar("device_id", default="") +SESSION_ID_CTX_VAR: Final[ContextVar[str]] = ContextVar("session_id", default="") +REQUEST_TIME_CTX_VAR: Final[ContextVar[str]] = ContextVar("request_time", default="") +SYSTEM_ID_CTX_VAR: Final[ContextVar[str]] = ContextVar("system_id", default="") +GW_SESSION_ID_CTX_VAR: Final[ContextVar[str]] = ContextVar("gw_session_id", default="") + + +class ContextVarsContainer: + @property + def request_id(self) -> str: + if not (request_id := REQUEST_ID_CTX_VAR.get()): + request_id = str(uuid.uuid4()) + REQUEST_ID_CTX_VAR.set(request_id) + return request_id + + def set_request_id(self, request_id: str = "") -> str: + if not request_id: + request_id = str(uuid.uuid4()) + REQUEST_ID_CTX_VAR.set(request_id) + return request_id + + @property + def device_id(self) -> str: + return DEVICE_ID_CTX_VAR.get() + + @device_id.setter + def device_id(self, value: str) -> None: + DEVICE_ID_CTX_VAR.set(value) + + @property + def session_id(self) -> str: + return SESSION_ID_CTX_VAR.get() + + @session_id.setter + def session_id(self, value: str) -> None: + SESSION_ID_CTX_VAR.set(value) + + @property + def request_time(self) -> str: + return REQUEST_TIME_CTX_VAR.get() + + @request_time.setter + def request_time(self, value: str) -> None: + REQUEST_TIME_CTX_VAR.set(value) + + @property + def system_id(self) -> str: + return SYSTEM_ID_CTX_VAR.get() + + @system_id.setter + def system_id(self, value: str) -> None: + SYSTEM_ID_CTX_VAR.set(value) + + @property + def gw_session_id(self) -> str: + return GW_SESSION_ID_CTX_VAR.get() + + @gw_session_id.setter + def gw_session_id(self, value: str) -> None: + GW_SESSION_ID_CTX_VAR.set(value) + + def set_context_vars(self, request_id: str = "", request_time: str = "", system_id: str = "", gw_session_id: str = "") -> None: + if request_id: + self.set_request_id(request_id) + if request_time: + self.request_time = request_time + if system_id: + self.system_id = system_id + if gw_session_id: + self.gw_session_id = gw_session_id + + def as_dict(self) -> dict: + return { + "request_id": self.request_id, + "device_id": self.device_id, + "session_id": self.session_id, + "request_time": self.request_time, + "system_id": self.system_id, + "gw_session_id": self.gw_session_id, + } diff --git a/src/dataloader/logger/logger.py b/src/dataloader/logger/logger.py new file mode 100644 index 0000000..9c52f51 --- /dev/null +++ b/src/dataloader/logger/logger.py @@ -0,0 +1,146 @@ +# Основной логгер приложения +import sys +import typing +from datetime import tzinfo + + +from loguru import logger + + +from .context_vars import ContextVarsContainer + + + +# Определяем фильтры для разных типов логов +def metric_only_filter(record: dict) -> bool: + return "metric" in record["extra"] + + + +def audit_only_filter(record: dict) -> bool: + return "audit" in record["extra"] + + + +def regular_log_filter(record: dict) -> bool: + return "metric" not in record["extra"] and "audit" not in record["extra"] + + + +class LoggerConfigurator: + def __init__( + self, + log_lvl: str, + log_file_path: str, + metric_file_path: str, + audit_file_path: str, + audit_host_ip: str, + audit_host_uid: str, + context_vars_container: ContextVarsContainer, + timezone: tzinfo, + ) -> None: + self.context_vars_container = context_vars_container + self.timezone = timezone + self.log_lvl = log_lvl + self.log_file_path = log_file_path + self.metric_file_path = metric_file_path + self.audit_file_path = audit_file_path + self.audit_host_ip = audit_host_ip + self.audit_host_uid = audit_host_uid + self._handler_ids = [] + self.configure_logger() + + + @property + def async_logger(self) -> "typing.Any": + return self._async_logger + + + def patch_record_with_context(self, record: dict) -> None: + context_data = self.context_vars_container.as_dict() + record["extra"].update(context_data) + if not record["extra"].get("request_id"): + record["extra"]["request_id"] = "system_event" + + + def configure_logger(self) -> None: + """Настройка логгера `loguru` с необходимыми обработчиками.""" + logger.remove() + logger.patch(self.patch_record_with_context) + + # Функция для безопасного форматирования консольных логов + def console_format(record): + request_id = record["extra"].get("request_id", "system_event") + elapsed = record["elapsed"] + level = record["level"].name + name = record["name"] + function = record["function"] + line = record["line"] + message = record["message"] + time_str = record["time"].strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + return ( + f"{time_str} ({elapsed}) | " + f"{request_id} | " + f"{level: <8} | " + f"{name}:{function}:{line} - " + f"{message}\n" + ) + + # Обработчик для обычных логов (консоль) + handler_id = logger.add( + sys.stdout, + level=self.log_lvl, + filter=regular_log_filter, + format=console_format, + colorize=True, + ) + self._handler_ids.append(handler_id) + + + # Обработчик для обычных логов (файл) + handler_id = logger.add( + self.log_file_path, + level=self.log_lvl, + filter=regular_log_filter, + rotation="10 MB", + compression="zip", + enqueue=True, + serialize=True, + ) + self._handler_ids.append(handler_id) + + + # Обработчик для метрик + handler_id = logger.add( + self.metric_file_path, + level="INFO", + filter=metric_only_filter, + rotation="10 MB", + compression="zip", + enqueue=True, + serialize=True, + ) + self._handler_ids.append(handler_id) + + + # Обработчик для аудита + handler_id = logger.add( + self.audit_file_path, + level="INFO", + filter=audit_only_filter, + rotation="10 MB", + compression="zip", + enqueue=True, + serialize=True, + ) + self._handler_ids.append(handler_id) + + + self._async_logger = logger + + + def remove_logger_handlers(self) -> None: + """Удаление всех обработчиков логгера.""" + for handler_id in self._handler_ids: + self._async_logger.remove(handler_id) diff --git a/src/dataloader/logger/models.py b/src/dataloader/logger/models.py new file mode 100644 index 0000000..23d0453 --- /dev/null +++ b/src/dataloader/logger/models.py @@ -0,0 +1 @@ +# Модели логов, метрик, событий аудита diff --git a/src/dataloader/logger/utils.py b/src/dataloader/logger/utils.py new file mode 100644 index 0000000..3481061 --- /dev/null +++ b/src/dataloader/logger/utils.py @@ -0,0 +1 @@ +# Функции + маскирование args diff --git a/src/dataloader/logger/uvicorn_logging_config.py b/src/dataloader/logger/uvicorn_logging_config.py new file mode 100644 index 0000000..3df67ab --- /dev/null +++ b/src/dataloader/logger/uvicorn_logging_config.py @@ -0,0 +1,63 @@ +# Конфигурация логирования uvicorn +import logging +import sys + + +from loguru import logger + + +class InterceptHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + # Get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +def setup_uvicorn_logging() -> None: + # Set all uvicorn loggers to use InterceptHandler + for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access"]: + log = logging.getLogger(logger_name) + log.handlers = [InterceptHandler()] + log.setLevel(logging.DEBUG) + log.propagate = False + + +# uvicorn logging config +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "default": { + "class": "logging.StreamHandler", + "level": "DEBUG", + }, + }, + "loggers": { + "uvicorn": { + "handlers": ["default"], + "level": "DEBUG", + }, + "uvicorn.error": { + "handlers": ["default"], + "level": "DEBUG", + }, + "uvicorn.access": { + "handlers": ["default"], + "level": "DEBUG", + }, + }, +}