diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 6d13e4ec..fbe57702 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -7,10 +7,22 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v7 - name: Assert tag is from master or release-candidate run: git branch -a --contains $(git describe --tags) | grep -E "master|release-candidate" - - name: Build and publish to pypi - uses: JRubics/poetry-publish@v2.1 + + - name: Setup Python + uses: actions/setup-python@v7 with: - pypi_token: ${{ secrets.PYPI_TOKEN }} + python-version: "3.13" + + - name: Install uv + run: python -m pip install --upgrade pip uv + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 58491c6c..332d1f01 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -10,20 +10,28 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - name: Setup dependencies - uses: ExpressApp/github-actions-poetry@v0.4 + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - poetry-version: "1.3.2" + + - name: Install uv + run: python -m pip install --upgrade pip uv + + - name: Install dependencies + run: uv sync --dev - name: Run tests env: BOT_CREDENTIALS: ${{ secrets.END_TO_END_TESTS_BOT_CREDENTIALS }} run: | - poetry run ./scripts/test --cov=pybotx --cov-report=xml + uv run ./scripts/test --cov=pybotx --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: true files: ./coverage.xml @@ -35,27 +43,43 @@ jobs: runs-on: ubuntu-22.04 steps: - - name: Setup dependencies - uses: ExpressApp/github-actions-poetry@v0.4 + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 with: - python-version: ${{ matrix.python-version }} - poetry-version: "1.3.2" + python-version: "3.13" + + - name: Install uv + run: python -m pip install --upgrade pip uv + + - name: Install dependencies + run: uv sync --dev - name: Run linters run: | - poetry run ./scripts/lint + uv run ./scripts/lint docs-lint: name: Docs lint runs-on: ubuntu-22.04 steps: - - name: Setup dependencies - uses: ExpressApp/github-actions-poetry@v0.4 + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 with: - python-version: ${{ matrix.python-version }} - poetry-version: "1.3.2" + python-version: "3.13" + + - name: Install uv + run: python -m pip install --upgrade pip uv + + - name: Install dependencies + run: uv sync --dev - name: Run linters run: | - poetry run bash ./scripts/docs-lint + uv run bash ./scripts/docs-lint diff --git a/.gitignore b/.gitignore index eca21968..b07a5008 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ htmlcov site .snippets*/ .idea/ +.hypothesis diff --git a/README.md b/README.md index e5e1ea28..724916eb 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,122 @@ ## Установка -Используя `poetry`: +Используя `uv`: ```bash -poetry add pybotx +uv add pybotx ``` +Для генерации нового production-ready бота из активного окружения: + +```bash +pybotx create bot my-bot +``` + +Или через `uv`, если окружение не активировано: + +```bash +uv run pybotx create bot my-bot +``` + +Для FSM-шаблона: + +```bash +uv run pybotx create bot my-fsm-bot --template production-fastapi-fsm +``` + +Или в пустой текущей директории: + +```bash +pybotx create bot +``` + +Сгенерированный проект включает: + +- FastAPI endpoints для Bot API +- `build_production_bot_preset()` по умолчанию +- `/metrics` с Prometheus registry +- `setup_healthcheck(...)` +- `dependency-injector` +- 4 слоя: `domain / application / infrastructure / presentation` +- пример domain port + repository stub с DI wiring +- `ARCHITECTURE.md` с правилами зависимостей и примерами размещения кода +- тесты, `ruff`, `mypy`, Docker-файлы + +Поддерживаемые встроенные шаблоны: + +- `production-fastapi` +- `production-fastapi-fsm` + +После генерации проекта можно добавить новую команду: + +```bash +uv run pybotx create command ping-users --project-dir my-bot +``` + +Чтобы добавить application service/use case без ручной раскладки по слоям: + +```bash +uv run pybotx create service sync-users --project-dir my-bot +``` + +Чтобы добавить только domain port без concrete adapter: + +```bash +uv run pybotx create port billing-gateway --project-dir my-bot +``` + +Чтобы добавить domain port + infrastructure repository stub с DI wiring: + +```bash +uv run pybotx create repository user-profile --project-dir my-bot +``` + +Если port уже существует, repository можно привязать к нему: + +```bash +uv run pybotx create repository stripe-billing-gateway --project-dir my-bot --port billing-gateway +``` + +Для FSM-шаблона можно сгенерировать новый flow c starter command и тестом: + +```bash +uv run pybotx create fsm-flow approval --project-dir my-fsm-bot +``` + +Для scaffold-проекта можно сгенерировать widget command на базе `WidgetFactory` и `widget_command`: + +```bash +uv run pybotx create widget deployment-approval --project-dir my-bot --kind confirm +``` + +Если нужен готовый flow `command + widget + follow-up services` без ручного wiring: + +```bash +uv run pybotx create widget-flow deployment-approval --project-dir my-bot --kind confirm +``` + +Сгенерированный scaffold использует следующий контракт: + +- `src//container.py` - composition root и DI +- `src//presentation/` - FastAPI, `pybotx` handlers, widgets, FSM +- `src//application/services/` - application services/use cases +- `src//domain/` - доменные модели и контракты, включая `domain/ports/` +- `src//infrastructure/` - config, repository/adapters, внешние интеграции + **Предупреждение:** Данный проект находится в активной разработке (`0.y.z`) и его API может быть изменён при повышении минорной версии. +## Документация по виджетам + +Подробное и исчерпывающее руководство по всем виджетам и API раннера: + +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/WIDGETS.md` + +Демо-бот с примерами всех виджетов: + +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/example/README.md` + ## Информация о мессенджере eXpress и платформе BotX @@ -58,11 +165,9 @@ poetry add pybotx ## Минимальный пример бота (интеграция с FastAPI) ```python -from http import HTTPStatus from uuid import UUID -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi import FastAPI # В этом и последующих примерах импорт из `pybotx` будет производиться # через звёздочку для краткости. Однако, это не является хорошей практикой. @@ -93,60 +198,525 @@ bot = Bot( ], ) +app: FastAPI = create_fastapi_bot_app( + bot=bot, + title="Example bot", + config=FastAPIBotAppConfig( + metrics_path="/metrics", + smartapp_request_path="/smartapps/request", + ), +) +``` + +`create_fastapi_bot_app(...)` регистрирует: + +- `POST /command` +- `GET /status` +- `POST /notification/callback` +- опционально `POST /smartapps/request` +- опционально `GET /metrics` +- healthcheck routes через `setup_healthcheck(...)` + +Если у вас уже есть свой `FastAPI()` объект, можно не создавать новый app, а +подключить `pybotx` в существующий: + +```python +from fastapi import FastAPI +from pybotx import * + app = FastAPI() -app.add_event_handler("startup", bot.startup) -app.add_event_handler("shutdown", bot.shutdown) +bot = Bot(collectors=[], bot_accounts=[]) +setup_fastapi_bot( + app, + bot=bot, + config=FastAPIBotAppConfig(metrics_path="/metrics"), +) +``` +### Healthcheck (опционально, подключается явно) -# На этот эндпоинт приходят команды BotX -# (сообщения и системные события). -@app.post("/command") -async def command_handler(request: Request) -> JSONResponse: - bot.async_execute_raw_bot_command( - await request.json(), - request_headers=request.headers, - ) - return JSONResponse( - build_command_accepted_response(), - status_code=HTTPStatus.ACCEPTED, - ) +По умолчанию `pybotx` не регистрирует healthcheck-эндпоинты. +Подключение выполняется явно: +```python +from fastapi import FastAPI +from pybotx import * -# На этот эндпоинт приходят события BotX для SmartApps, обрабатываемые синхронно. -@app.post("/smartapps/request") -async def sync_smartapp_event_handler(request: Request) -> JSONResponse: - response = await bot.sync_execute_raw_smartapp_event( - await request.json(), - request_headers=request.headers, - ) - return JSONResponse(response.jsonable_dict(), status_code=HTTPStatus.OK) +app = FastAPI() +healthcheck = setup_healthcheck(app) # Роуты: /health/ и /health/ready -# К этому эндпоинту BotX обращается, чтобы узнать -# доступность бота и его список команд. -@app.get("/status") -async def status_handler(request: Request) -> JSONResponse: - status = await bot.raw_get_status( - dict(request.query_params), - request_headers=request.headers, - ) - return JSONResponse(status) +async def db_readiness_check() -> ReadinessCheckResult: + # Пример: реальная проверка БД/кеша/внешнего API. + # Рекомендуется выставлять таймауты внутри проверки. + return ReadinessCheckResult(status="ok") -# На этот эндпоинт приходят коллбэки с результатами -# выполнения асинхронных методов в BotX. -@app.post("/notification/callback") -async def callback_handler(request: Request) -> JSONResponse: - await bot.set_raw_botx_method_result( - await request.json(), - verify_request=False, - ) - return JSONResponse( - build_command_accepted_response(), - status_code=HTTPStatus.ACCEPTED, - ) + +healthcheck.add_readiness_check( + ReadinessCheck( + name="db", + check=db_readiness_check, + critical=True, + timeout_seconds=0.5, + ), +) +``` + +`/health/`: +- `200` — процесс жив (`status=ok`) +- `500` — фатальное состояние (`status=fail`) + +`/health/ready`: +- `200` — готов (`status=ok`) или частично деградирован (`status=degraded`) +- `503` — не готов (`status=fail`, если упал хотя бы один `critical` check) + +### HTTP-клиент, retry, metrics и tracing + +`pybotx` создаёт внутренний `httpx.AsyncClient` с безопасными значениями +`timeout` и `limits`. При необходимости их можно переопределить в `Bot(...)` +через `httpx_timeout` и `httpx_limits`. + +`retry_policy` по умолчанию выключен (`None`), то есть повторных попыток нет. +Для включения ретраев передайте `BotXRetryPolicy`. Начиная с текущей версии +retry в `pybotx` стал safe-by-default: если политика включена, библиотека +автоматически повторяет только те BotX-запросы, которые считаются безопасными +для автоматического повтора. + +```python +import httpx +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + httpx_timeout=httpx.Timeout(connect=2.0, read=20.0, write=10.0, pool=1.0), + httpx_limits=httpx.Limits(max_connections=800, max_keepalive_connections=200), + retry_policy=BotXRetryPolicy( + max_attempts=4, + initial_delay_seconds=0.2, + max_delay_seconds=5.0, + jitter_seconds=0.2, + retryable_status_codes=frozenset({408, 429, 500, 502, 503, 504}), + ), +) +``` + +Retry-политика применяется ко всем вызовам BotX API и повторяет запросы при: +- сетевых/timeout ошибках транспорта `httpx` +- HTTP-статусах из `retryable_status_codes` + +Важно: BotX async-методы обычно создают side effect и возвращают только `sync_id`, +который появляется уже после успешного ответа. Поэтому ретраи на write-вызовах +могут повторить операцию, если первый запрос был принят BotX, но клиент не получил +ответ. По этой причине `retry_policy=None` оставлен значением по умолчанию, а +в production ретраи лучше включать осознанно и с пониманием семантики конкретного +метода. + +По умолчанию используется `SafeBotXRetryRequestPolicy`: +- retry разрешен для read-only запросов (`GET`, `HEAD`, `OPTIONS`) +- отдельно allowlist’ится read-only `POST /api/v3/botx/users/by_email` +- write-операции (`notifications`, `events`, `smartapps`, `create/update/delete`, + `upload`, `metrics`) по умолчанию не ретраятся автоматически + +Это поведение можно переопределить через `retry_request_policy`. + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=RetryAllBotXRequestsPolicy(), +) +``` + +Для точечного allowlist есть `PathAllowlistBotXRetryRequestPolicy`: + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=PathAllowlistBotXRetryRequestPolicy( + allowed_requests={ + ("GET", "/api/v3/botx/chats/info"), + ("GET", "/api/v3/botx/events/{uuid}/status"), + ("POST", "/api/v3/botx/users/by_email"), + }, + ), +) +``` + +Для allowlist по библиотечным операциям есть +`OperationNameAllowlistBotXRetryRequestPolicy`. `operation_name` по умолчанию +равен имени client method class, например `MessageStatusMethod`, +`ChatInfoMethod`, `DirectNotificationMethod`. Для built-in операций есть typed +catalog `BotXOperation`. + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={ + BotXOperation.MESSAGE_STATUS, + BotXOperation.CHAT_INFO, + }, + ), +) +``` + +Есть готовый preset по built-in safe операциям: + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=KnownSafeBotXRetryRequestPolicy(), +) +``` + +Для комбинирования safe default с дополнительными исключениями есть +`AnyOfBotXRetryRequestPolicy`: + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=AnyOfBotXRetryRequestPolicy( + policies=( + SafeBotXRetryRequestPolicy(), + OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={"DirectNotificationMethod"}, + ), + ), + ), +) +``` + +Подробное объяснение решения и рекомендаций для production: +[`docs/retry_safety.md`](docs/retry_safety.md). + +Для сокращения boilerplate при сборке production-конфига есть готовые preset +builders: + +```python +from pybotx import Bot, build_production_bot_preset + +production_preset = build_production_bot_preset() + +bot = Bot( + collectors=[], + bot_accounts=[], + **production_preset.as_bot_kwargs(), +) +``` + +Если нужно отдельно настраивать retry и observability: + +```python +from pybotx import ( + Bot, + build_production_bot_preset, + build_production_observability_preset, + build_production_retry_preset, +) + +production_preset = build_production_bot_preset( + retry_preset=build_production_retry_preset(max_attempts=5), + observability_preset=build_production_observability_preset( + enable_open_telemetry_tracing=False, + ), +) + +bot = Bot( + collectors=[], + bot_accounts=[], + **production_preset.as_bot_kwargs(), +) ``` +Подробности по preset layer: +[`docs/production_presets.md`](docs/production_presets.md). + +Если передан собственный `httpx_client`, параметры `httpx_timeout` и +`httpx_limits` передавать нельзя. + +Для кастомной логики ретраев можно передать собственный `retry_strategy` +(протокол `BotXRetryStrategy`): + +```python +from collections.abc import Callable + +from tenacity import ( + AsyncRetrying, + RetryCallState, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) +from pybotx import * + + +class CustomRetryStrategy(BotXRetryStrategy): + def build_retrying( + self, + *, + retry_policy: BotXRetryPolicy, + retry_exceptions: tuple[type[BaseException], ...], + before_sleep: Callable[[RetryCallState], None], + ) -> AsyncRetrying: + return AsyncRetrying( + stop=stop_after_attempt(2), + wait=wait_fixed(0.1), + retry=retry_if_exception_type(retry_exceptions), + before_sleep=before_sleep, + reraise=True, + ) + + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=5), + retry_strategy=CustomRetryStrategy(), +) +``` + +Также доступны opt-in хуки наблюдаемости: +- `metrics_collector` +- `tracing_collector` + +Оба параметра по умолчанию выключены (`None`). + +Входящая observability в `pybotx` теперь включена по умолчанию: +- Структурированные JSON-логи. +- Корреляционные поля в каждом логе: `trace_id`, `request_id`, `chat_id`, `bot_id`. +- Встроенный ingress-коллектор метрик (`InMemoryIngressMetricsCollector`) в `Bot`. + +По умолчанию `request_id` берется из заголовков (`X-Request-Id`, `X-Correlation-Id`, +`Request-Id`) или из `sync_id`, а `trace_id` из (`X-Trace-Id`, `traceparent`, `b3`) +или из `request_id`. Если Express/BotX не присылает такие заголовки, `pybotx` +использует то, что реально есть в протоколе: `sync_id` для Bot API команд и +callback-ов, а для sync smartapp event оставляет поле пустым, пока внешний ingress +не добавит correlation id. + +Для ingress-метрик можно передать кастомный collector через: +- `ingress_metrics_collector` + +Есть готовая реализация для Prometheus (опциональная зависимость): +- `PrometheusIngressMetricsCollector` +- Требуется `prometheus-client` (`uv add prometheus-client`) + +```python +from pybotx import * + +ingress_metrics = PrometheusIngressMetricsCollector() + +bot = Bot( + collectors=[], + bot_accounts=[], + ingress_metrics_collector=ingress_metrics, +) +``` + +Ingress-метрики по умолчанию: +- `pybotx_ingress_latency_seconds{command_kind,command_name,outcome}` +- `pybotx_ingress_errors_total{command_kind,command_name,error_type}` +- `pybotx_ingress_rejected_total{command_kind,command_name,reason}` +- `pybotx_ingress_queue_depth` + +Готовая реализация метрик для Prometheus: +- `PrometheusMetricsCollector` (latency/error/retry counters с label’ами) +- Требуется `prometheus-client` (`uv add prometheus-client`) + +```python +from pybotx import * + +prometheus_metrics = PrometheusMetricsCollector() + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + metrics_collector=prometheus_metrics, +) +``` + +Можно передать нормализаторы для контроля кардинальности label-ов: + +```python +from pybotx import PrometheusMetricsCollector + +prometheus_metrics = PrometheusMetricsCollector( + path_normalizer=lambda url: "/normalized/path", + reason_normalizer=lambda reason: reason.split(":", 1)[0], +) +``` + +Метрики по умолчанию: +- `pybotx_botx_requests_total{method,path,status,outcome}` +- `pybotx_botx_request_errors_total{method,path,status,error_type}` +- `pybotx_botx_request_retries_total{method,path,reason}` +- `pybotx_botx_request_latency_seconds{method,path,status}` + +Готовая реализация трассировки для OpenTelemetry: +- `OpenTelemetryTracingCollector` (span на запрос + retry events как span events) +- Требуется `opentelemetry-api` (`uv add opentelemetry-api`) +- Для экспорта спанов обычно нужен `opentelemetry-sdk` + +```python +from pybotx import * + +otel_tracing = OpenTelemetryTracingCollector(tracer_name="mybot.pybotx") + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + tracing_collector=otel_tracing, +) +``` + +Также можно обогащать спан через `span_enricher`: + +```python +from typing import Any + +from pybotx import BotXRequestMetadata, OpenTelemetryTracingCollector + + +def span_enricher(span: Any, metadata: BotXRequestMetadata) -> None: + span.set_attribute("bot.name", "mybot") + span.set_attribute("botx.request_method", metadata.method) + + +otel_tracing = OpenTelemetryTracingCollector( + tracer_name="mybot.pybotx", + span_enricher=span_enricher, +) +``` + +```python +from pybotx import * + + +class MetricsCollector: + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + # Пример: увеличение счётчика in-flight + pass + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + # Пример: счётчик повторов с reason/status + pass + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + # Пример: latency histogram + error counter + pass + + +class TracingCollector: + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + pass + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + pass + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + pass + + +bot = Bot( + collectors=[], + bot_accounts=[], + retry_policy=BotXRetryPolicy(max_attempts=3), + metrics_collector=MetricsCollector(), + tracing_collector=TracingCollector(), +) +``` + +### Ограничение параллелизма входящих команд (Queue + Semaphore + rejection policy) + +`pybotx` обрабатывает входящие команды через bounded pipeline: +- очередь (`Queue`) ограничивает backlog; +- воркеры и `Semaphore` ограничивают параллелизм; +- стратегия перегруза решает, что делать при переполнении очереди. + +Параметры задаются через `command_processing_config` в `Bot(...)`. +По умолчанию: `max_concurrency=100`, `max_queue_size=1000`, +`RejectNewBotCommandOverloadStrategy`. + +```python +from pybotx import * + +bot = Bot( + collectors=[], + bot_accounts=[], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=64, + max_queue_size=2000, + overload_strategy=RejectNewBotCommandOverloadStrategy(), + ), +) +``` + +Встроенные стратегии перегруза: +- `RejectNewBotCommandOverloadStrategy` — отклоняет новый входящий command. +- `DropOldestBotCommandOverloadStrategy` — выбрасывает самый старый queued command и принимает новый. + +Можно реализовать свою стратегию через `BotCommandOverloadStrategy`: + +```python +from pybotx import BotCommandOverloadAction, BotCommandOverloadStrategy + + +class PreferDropOldestOnBigBurst(BotCommandOverloadStrategy): + def on_queue_overflow( + self, + *, + queue_size: int, + queue_max_size: int, + ) -> BotCommandOverloadAction: + if queue_size > queue_max_size // 2: + return BotCommandOverloadAction.DROP_OLDEST + return BotCommandOverloadAction.REJECT_NEW +``` + +Если вы `await`-ите `bot.async_execute_bot_command(...)`, при отклонении получите +`BotCommandRejectedError`. + +Trade-off: в пике увеличивается latency из-за очереди, зато предсказуемо ограничиваются +память/конкурентность и снижается риск каскадных отказов. + ## Примеры @@ -643,9 +1213,10 @@ async def search_user_handler(message: IncomingMessage, bot: Bot) -> None: user_info = await bot.search_user_by_huid( bot_id=message.bot.id, huid=message.sender.huid, + trusts_search=True, ) - except UserNotFoundError: # Если пользователь и бот находятся на разных CTS - await bot.answer_message("User not found. Maybe you are on a different cts.") + except UserNotFoundError: + await bot.answer_message("User not found.") return await bot.answer_message(f"Your info:\n{dataclasses.asdict(user_info)}\n") diff --git a/docs/production_presets.md b/docs/production_presets.md new file mode 100644 index 00000000..979bce86 --- /dev/null +++ b/docs/production_presets.md @@ -0,0 +1,163 @@ +# Production presets in pybotx + +## Зачем это нужно + +После добавления retry, metrics и tracing в `pybotx` основной сценарий выглядел +правильно, но был слишком многословным: + +```python +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(...), + retry_request_policy=..., + metrics_collector=PrometheusMetricsCollector(...), + tracing_collector=OpenTelemetryTracingCollector(...), + ingress_metrics_collector=PrometheusIngressMetricsCollector(...), +) +``` + +Для production это нормальная мощность API, но плохой DX для типового проекта. +Поэтому в библиотеку добавлен отдельный factory/preset layer. + +## Что добавлено + +- `BotRetryPreset` +- `BotObservabilityPreset` +- `BotProductionPreset` +- `build_production_retry_preset()` +- `build_production_observability_preset()` +- `build_production_bot_preset()` + +Каждый preset - immutable value object, который можно превратить в kwargs для +`Bot` через `as_bot_kwargs()`. + +## Базовый сценарий + +```python +from pybotx import Bot, build_production_bot_preset + +production_preset = build_production_bot_preset() + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + **production_preset.as_bot_kwargs(), +) +``` + +По умолчанию такой preset собирает: + +- production retry preset; +- `PrometheusMetricsCollector` для outgoing BotX requests; +- `PrometheusIngressMetricsCollector` для ingress queue/latency/errors/rejected; +- `OpenTelemetryTracingCollector` для outgoing BotX requests. + +Для этого нужны optional dependencies: + +```bash +uv add prometheus-client opentelemetry-api +``` + +## Retry preset + +`build_production_retry_preset()` включает: + +- `BotXRetryPolicy` с production defaults из `pybotx`; +- composable `retry_request_policy`: + - `SafeBotXRetryRequestPolicy()` + - `KnownSafeBotXRetryRequestPolicy()` + +Это дает такой результат: + +- generic safe retry для `GET/HEAD/OPTIONS`; +- safe retry для read-only `POST`, которые уже известны библиотеке; +- safe-by-default поведение для стандартных built-in методов `pybotx`. + +Пример кастомизации: + +```python +from pybotx import BotXOperation, build_production_retry_preset + +retry_preset = build_production_retry_preset( + max_attempts=5, + extra_safe_operations={BotXOperation.DIRECT_NOTIFICATION}, +) +``` + +Если передан свой `retry_request_policy`, одновременно передавать +`extra_safe_requests` или `extra_safe_operations` нельзя: builder это запрещает, +чтобы не было неявного precedence. + +## Observability preset + +`build_production_observability_preset()` по умолчанию собирает: + +- outgoing Prometheus metrics; +- ingress Prometheus metrics; +- OpenTelemetry tracing. + +Пример отключить tracing, но оставить metrics: + +```python +from pybotx import build_production_observability_preset + +observability_preset = build_production_observability_preset( + enable_open_telemetry_tracing=False, +) +``` + +Пример наоборот, только tracing: + +```python +from pybotx import build_production_observability_preset + +observability_preset = build_production_observability_preset( + enable_prometheus_metrics=False, + tracer_name="mybot.pybotx", +) +``` + +Если выключить оба backend одновременно, builder поднимет `ValueError`. + +## Сборка полного production preset + +Если нужен полный preset с кастомизированными частями: + +```python +from pybotx import ( + build_production_bot_preset, + build_production_observability_preset, + build_production_retry_preset, +) + +production_preset = build_production_bot_preset( + retry_preset=build_production_retry_preset(max_attempts=5), + observability_preset=build_production_observability_preset( + enable_open_telemetry_tracing=False, + ), +) +``` + +## Почему это отдельный layer, а не новый аргумент в Bot + +Выбран паттерн `Factory + Value Object`. + +Причина: + +- не раздувает `Bot.__init__` еще сильнее; +- не вводит сложные precedence rules между `preset` и обычными kwargs; +- оставляет текущий `Bot` API обратно совместимым; +- упрощает документацию и тестирование. + +Альтернатива была такой: + +- добавить `production_preset=` в `Bot` + +Но тогда пришлось бы отдельно определять: + +- кто кого переопределяет; +- можно ли смешивать preset и raw kwargs; +- как валидировать конфликтующие параметры. + +Для текущей задачи отдельный preset layer проще и чище. diff --git a/docs/retry_safety.md b/docs/retry_safety.md new file mode 100644 index 00000000..c2f63e08 --- /dev/null +++ b/docs/retry_safety.md @@ -0,0 +1,355 @@ +# Retry safety in pybotx + +## Зачем это понадобилось + +У BotX есть важная особенность: многие методы не просто возвращают данные, а +создают side effect в Express/BotX. Например: + +- отправка сообщения +- отправка smartapp event +- создание чата/треда +- изменение сообщения +- загрузка файла + +Для таких методов серверный side effect может уже произойти, даже если клиент не +успел получить HTTP-ответ из-за timeout, разрыва соединения или временной ошибки +сети. + +В результате слепой automatic retry может привести к дубликатам: + +1. бот отправил запрос в BotX; +2. BotX принял запрос и создал side effect; +3. ответ не дошел до бота; +4. клиент повторил запрос; +5. BotX выполнил операцию еще раз. + +Особенно это критично для async методов, где `sync_id` появляется только после +успешного HTTP-ответа, а финальный результат приходит отдельно через callback. + +## Что делает pybotx теперь + +В `pybotx` retry разделен на две части: + +- `retry_policy` + Задает backoff, jitter, лимит попыток и retryable HTTP status codes. +- `retry_request_policy` + Решает, какие именно BotX-запросы вообще разрешено автоматически повторять. + +Если `retry_policy=None`, retry полностью выключен. + +Если `retry_policy` включен, но `retry_request_policy` не передан, `pybotx` +использует безопасный default: + +- `SafeBotXRetryRequestPolicy` + +Кроме path-based allowlist теперь есть operation-based allowlist: + +- `OperationNameAllowlistBotXRetryRequestPolicy` +- `KnownSafeBotXRetryRequestPolicy` + +`operation_name` в metadata равен имени client method class, например: + +- `MessageStatusMethod` +- `ChatInfoMethod` +- `DirectNotificationMethod` + +Для built-in client methods есть typed catalog: + +- `BotXOperation` + +## SafeBotXRetryRequestPolicy + +Это default policy для production. + +Она разрешает retry только для read-only запросов: + +- `GET` +- `HEAD` +- `OPTIONS` + +И отдельно allowlist’ит read-only POST: + +- `POST /api/v3/botx/users/by_email` + +Почему allowlist нужен отдельно: в BotX есть read-операции, которые исторически +идут через `POST`, поэтому правило только по HTTP-методу было бы слишком грубым. + +`SafeBotXRetryRequestPolicy` также можно расширять через: + +- `extra_safe_requests` +- `extra_safe_operation_names` + +## Что safe policy не retry'ит + +По умолчанию не ретраятся операции, у которых высокий риск дублирования side +effect: + +- notifications +- events +- smartapps mutations +- create/update/delete operations +- file upload +- metrics ingestion + +Это осознанное решение: лучше не повторить потенциально unsafe операцию, чем +получить скрытые дубликаты и несогласованное состояние. + +## Когда использовать RetryAllBotXRequestsPolicy + +`RetryAllBotXRequestsPolicy` нужен только если вы точно понимаете последствия и +берете ответственность за идемпотентность на себя. + +Подходит, если: + +- у вас есть внешний слой дедупликации; +- конкретный BotX endpoint безопасен в вашей интеграции; +- вы повторяете только те операции, для которых дубли допустимы. + +Пример: + +```python +from pybotx import Bot, BotXRetryPolicy, RetryAllBotXRequestsPolicy + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=RetryAllBotXRequestsPolicy(), +) +``` + +## Когда использовать PathAllowlistBotXRetryRequestPolicy + +Если нужен не глобальный `retry all`, а точечный control plane, используйте +`PathAllowlistBotXRetryRequestPolicy`. + +Пример: + +```python +from pybotx import ( + Bot, + BotXRetryPolicy, + PathAllowlistBotXRetryRequestPolicy, +) + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=PathAllowlistBotXRetryRequestPolicy( + allowed_requests={ + ("GET", "/api/v3/botx/chats/info"), + ("GET", "/api/v3/botx/events/{uuid}/status"), + ("POST", "/api/v3/botx/users/by_email"), + }, + ), +) +``` + +Особенности: + +- policy нормализует UUID-сегменты пути в вид `/{uuid}`; +- allowlist задается по паре `(method, normalized_path)`; +- это дает стабильную low-cardinality настройку без привязки к конкретным id. + +## Когда использовать OperationNameAllowlistBotXRetryRequestPolicy + +Если вы хотите описывать retry-политику в терминах библиотеки, а не HTTP path, +используйте `OperationNameAllowlistBotXRetryRequestPolicy`. + +Пример: + +```python +from pybotx import ( + Bot, + BotXOperation, + BotXRetryPolicy, + OperationNameAllowlistBotXRetryRequestPolicy, +) + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={ + BotXOperation.MESSAGE_STATUS, + BotXOperation.CHAT_INFO, + }, + ), +) +``` + +Плюсы: + +- конфиг ближе к публичным возможностям `pybotx`; +- не нужно помнить raw path; +- меньше риска ошибиться в URL version/prefix. + +Минусы: + +- имя класса зависит от library API; +- если в библиотеке появится новый client method class, allowlist надо обновить. + +## Когда использовать KnownSafeBotXRetryRequestPolicy + +Если вам нужен минимальный DX friction и library-provided preset, используйте +`KnownSafeBotXRetryRequestPolicy`. + +Он разрешает retry только для built-in safe operations из каталога `BotXOperation`. + +Пример: + +```python +from pybotx import Bot, BotXRetryPolicy, KnownSafeBotXRetryRequestPolicy + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=KnownSafeBotXRetryRequestPolicy(), +) +``` + +Если нужно расширить preset: + +```python +from pybotx import ( + Bot, + BotXOperation, + BotXRetryPolicy, + KnownSafeBotXRetryRequestPolicy, +) + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=KnownSafeBotXRetryRequestPolicy( + extra_operations={BotXOperation.DIRECT_NOTIFICATION}, + ), +) +``` + +Такое расширение считается risky и на startup библиотека пишет warning. + +## Known-safe matrix + +Built-in safe catalog сейчас включает: + +| Operation | operation_name | Default retry | +| --- | --- | --- | +| `BotXOperation.BOTS_LIST` | `BotsListMethod` | yes | +| `BotXOperation.CHAT_INFO` | `ChatInfoMethod` | yes | +| `BotXOperation.DOWNLOAD_FILE` | `DownloadFileMethod` | yes | +| `BotXOperation.GET_STICKER` | `GetStickerMethod` | yes | +| `BotXOperation.GET_STICKER_PACK` | `GetStickerPackMethod` | yes | +| `BotXOperation.GET_STICKER_PACKS` | `GetStickerPacksMethod` | yes | +| `BotXOperation.LIST_CHATS` | `ListChatsMethod` | yes | +| `BotXOperation.MESSAGE_STATUS` | `MessageStatusMethod` | yes | +| `BotXOperation.PERSONAL_CHAT` | `PersonalChatMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_EMAIL_GET` | `SearchUserByEmailMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_EMAIL_POST` | `SearchUserByEmailPostMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_EMAILS` | `SearchUserByEmailsMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_HUID` | `SearchUserByHUIDMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_LOGIN` | `SearchUserByLoginMethod` | yes | +| `BotXOperation.SEARCH_USER_BY_OTHER_ID` | `SearchUserByOtherIdMethod` | yes | +| `BotXOperation.SMARTAPPS_LIST` | `SmartAppsListMethod` | yes | +| `BotXOperation.USERS_AS_CSV` | `UsersAsCSVMethod` | yes | + +Примеры built-in risky operations: + +| Operation | operation_name | Why not safe by default | +| --- | --- | --- | +| `BotXOperation.DIRECT_NOTIFICATION` | `DirectNotificationMethod` | creates side effect | +| `BotXOperation.DIRECT_NOTIFICATION_SYNC` | `DirectNotificationSyncMethod` | creates side effect | +| `BotXOperation.INTERNAL_BOT_NOTIFICATION` | `InternalBotNotificationMethod` | async callback semantics | +| `BotXOperation.SMARTAPP_EVENT` | `SmartAppEventMethod` | creates side effect | +| `BotXOperation.CREATE_CHAT` | `CreateChatMethod` | mutation | +| `BotXOperation.EDIT_EVENT` | `EditEventMethod` | mutation | +| `BotXOperation.FILES_UPLOAD_FILE` | `FilesUploadFileMethod` | upload / side effect | + +## Комбинирование политик + +Для composable-конфигурации в `pybotx` есть `AnyOfBotXRetryRequestPolicy`. + +Пример: сохранить safe default, но отдельно разрешить retry для конкретной +операции: + +```python +from pybotx import ( + AnyOfBotXRetryRequestPolicy, + Bot, + BotXRetryPolicy, + OperationNameAllowlistBotXRetryRequestPolicy, + SafeBotXRetryRequestPolicy, +) + +bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=3), + retry_request_policy=AnyOfBotXRetryRequestPolicy( + policies=( + SafeBotXRetryRequestPolicy(), + OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={"DirectNotificationMethod"}, + ), + ), + ), +) +``` + +## Почему не Chain of Responsibility + +Для текущей задачи в `pybotx` выбрана комбинация паттернов: + +- `Strategy` + `BotXRetryRequestPolicy` инкапсулирует правило принятия решения. +- `Composite` + `AnyOfBotXRetryRequestPolicy` позволяет собирать несколько policy вместе. + +`Chain of Responsibility` здесь пока избыточен, потому что у нас сейчас простая +булева модель: policy либо разрешает retry, либо нет. + +CoR стал бы оправдан, если бы понадобилась трехсостояний модель: + +- `allow` +- `deny` +- `abstain` + +И если бы была важна именно приоритетная последовательность правил, например: + +1. глобальный deny-list; +2. затем operation allowlist; +3. затем fallback safe default. + +Пока такая сложность в `pybotx` не нужна. Для production-конфига с текущими +требованиями `Strategy + Composite` дает более простой и предсказуемый API. + +## Рекомендация для production + +Рекомендуемый порядок: + +1. Начинать с `SafeBotXRetryRequestPolicy`. +2. Добавлять `PathAllowlistBotXRetryRequestPolicy` только для тех endpoint'ов, + чья семантика вам точно понятна. +3. Использовать `RetryAllBotXRequestsPolicy` только как явный opt-in. +4. Для write-операций опираться не на retry, а на: + - timeout/limits; + - callback handling; + - observability; + - прикладную идемпотентность на вашей стороне, если она действительно нужна. + +## Как это связано с observability + +Retry events по-прежнему попадают в: + +- `PrometheusMetricsCollector` +- `OpenTelemetryTracingCollector` + +Но теперь retry event означает не просто "был retry policy", а +"был retry policy и этот запрос был признан допустимым для автоматического +повтора". + +Это делает telemetry ближе к реальному operational поведению. diff --git a/docs/widgets/WIDGETS.md b/docs/widgets/WIDGETS.md new file mode 100644 index 00000000..69b4778d --- /dev/null +++ b/docs/widgets/WIDGETS.md @@ -0,0 +1,1209 @@ +# Виджеты в `pybotx`: полное руководство + +Этот документ является полной справкой по подсистеме виджетов в `pybotx`. +В нем описано: + +- все готовые виджеты из `pybotx.widgets` +- модель выполнения: `WidgetRunner`, `RunnerHookResult`, `@widget_command` +- фабрики хуков: `on_data_key`, `on_completed`, `on_action` +- как писать собственные виджеты +- практические паттерны и рекомендации по тестированию + +## 1. Быстрый старт + +Минимальная команда с виджетом и автоматическим раннером: + +```python +from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import ConfirmWidget, on_action, widget_command +from pybotx.widgets.confirm import CANCEL_ACTION, CONFIRM_ACTION + +collector = HandlerCollector() + + +@collector.command("/confirm-demo", description="Confirm widget demo") +@widget_command( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: "Confirmed", + CANCEL_ACTION: "Cancelled", + }, + ), +) +async def confirm_demo_handler(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + return ConfirmWidget( + label="Confirm deployment?", + message=message, + bot=bot, + command="/confirm-demo", + ) +``` + +Что происходит: + +1. хендлер создает и возвращает виджет +2. `@widget_command` оборачивает хендлер и запускает `WidgetRunner` +3. хук `before` обрабатывает нажатие кнопки и отправляет результат +4. если callback-данных нет, отображается сам виджет + +### 1.1 `WidgetContext` и `WidgetFactory` + +Если в хендлере нужно создать несколько виджетов с одними и теми же +`message/bot/command`, удобнее использовать фабрику: + +```python +from pybotx import Bot, IncomingMessage +from pybotx.widgets import WidgetContext, WidgetFactory, widget_command + + +@widget_command() +async def confirm_demo_handler(message: IncomingMessage, bot: Bot): + widgets = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/confirm-demo", + ), + ) + return widgets.confirm(label="Confirm deployment?") +``` + +`WidgetFactory` сейчас покрывает: + +- `confirm(...)` +- `approval(...)` +- `select(...)` +- `search_select(...)` +- `multi_select(...)` +- `form_wizard(...)` +- `table(...)` +- `calendar(...)` +- `range(...)` +- `carousel(...)` +- `pagination(...)` +- `messages_pager(...)` +- `date_range(...)` +- `async_job(...)` +- `file_batch(...)` +- `checklist(...)` +- `checktable(...)` + +Для повторяемых текстов и UI-defaults можно передать `WidgetDefaults`. + +## 2. Архитектура и жизненный цикл + +### 2.1 Базовый класс `Widget` + +Каждый виджет: + +- получает `message`, `bot`, `command` +- создает `widget_message` (`OutgoingMessage`) +- заполняет `widget_message.body` +- добавляет bubbles/keyboard в `add_markup()` +- отображается через `display()` + +Обязательный метод: + +- `add_markup(self) -> None` (абстрактный) + +Полезные методы: + +- `display()`: `add_markup()` + `send_widget_message()` +- `send_result(body, markup=None)`: отправляет результат как edit или как новое сообщение +- `send_or_update_message(widget_message)`: внутренняя логика send/edit +- `add_additional_markup()`: объединяет внешний `MessageMarkup` + +### 2.2 Семантика отправки/редактирования + +Служебные метаданные: + +- сообщения виджетов содержат `pybotx_widget = 1` в metadata +- ключ режима результата: `pybotx_widget_result_mode` + +Поведение display/update: + +- если входящее сообщение имеет `pybotx_widget=1` и `source_sync_id`, виджет пытается редактировать предыдущее сообщение +- иначе виджет отправляет новое сообщение + +### 2.3 Режим результата (`edit` vs `message`) + +- `WIDGET_RESULT_MODE_EDIT = "edit"` +- `WIDGET_RESULT_MODE_MESSAGE = "message"` + +Приоритет выбора режима в `Widget._resolve_result_mode()`: + +1. явный `result_mode`, переданный в конструктор виджета +2. режим из входящей metadata (`pybotx_widget_result_mode`) +3. режим из аргумента команды: + `edit|inline|update` -> `edit`, + `message|reply|send` -> `message`, + также поддерживаются `mode=...`, `result=...`, `--mode=...`, `--result=...` +4. значение по умолчанию: `edit` + +## 3. API раннера + +### 3.1 `RunnerHookResult` + +`RunnerHookResult` управляет потоком выполнения в `before`-хуке: + +- `result: str | None` -> если задан, отправляется текст результата +- `should_display: bool` -> если `False`, после хука виджет не отображается +- `widget: Widget | None` -> замена текущего виджета (например, после пересборки состояния) + +### 3.2 `WidgetRunner` + +`WidgetRunner(message, bot, config=None)` и `WidgetRunner.run(widget, before=None, after=None)`: + +- поддерживает общий `WidgetRunnerConfig`: + - `before_hooks` + - `after_hooks` + - `observers` +- запускает config-level `before_hooks`, а затем per-call `before` +- применяет результат `RunnerHookResult` +- вызывает `widget.display()` +- запускает per-call `after`, а затем config-level `after_hooks` +- каждый `after`-хук, вернувший текст, отправляет его как результат +- уведомляет observers о старте и завершении выполнения + +Сигнатуры хуков: + +- `before(widget) -> RunnerHookResult | None | awaitable` +- `after(widget) -> str | None | awaitable` + +Для observability есть встроенные типы: + +- `WidgetRunnerStartedEvent` +- `WidgetRunnerFinishedEvent` +- `WidgetRunnerObserver` +- `LoggingWidgetRunnerObserver` + +Важно: + +- observer-ошибки не валят обработку виджета, а только логируются +- ошибки самих хуков и `widget.display()` не глотаются и продолжают подниматься вверх + +### 3.3 `@widget_command` + +Декоратор из `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/pybotx/widgets/runner.py`. + +Поддерживаемые формы: + +```python +@widget_command +async def handler(message: IncomingMessage, bot: Bot) -> Widget: ... + +@widget_command(before=..., after=...) +async def handler(message: IncomingMessage, bot: Bot) -> Widget: ... + +@widget_command(config=WidgetRunnerConfig(...)) +async def handler(message: IncomingMessage, bot: Bot) -> Widget: ... +``` + +Поведение: + +- хендлер должен вернуть виджет (или `None`) +- декоратор умеет резолвить awaitable-результат +- если получен `None`, выполнение завершается без действий +- иначе создается `WidgetRunner(message, bot, config=config)` и выполняются хуки + display + +Зачем использовать: + +- убирает boilerplate с ручным созданием runner в каждой команде +- сохраняет хендлер тонким: только сборка виджета + +Пример production-friendly конфигурации: + +```python +from pybotx import Bot, IncomingMessage +from pybotx.widgets import ( + ConfirmWidget, + LoggingWidgetRunnerObserver, + WidgetRunnerConfig, + on_action, + widget_command, +) +from pybotx.widgets.confirm import CANCEL_ACTION, CONFIRM_ACTION + +widget_config = WidgetRunnerConfig.from_hooks( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: "Confirmed", + CANCEL_ACTION: "Cancelled", + }, + ), + observers=(LoggingWidgetRunnerObserver(),), +) + + +@widget_command(config=widget_config) +async def confirm_demo_handler(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + return ConfirmWidget( + label="Confirm deployment?", + message=message, + bot=bot, + command="/confirm-demo", + ) +``` + +Если не хочется собирать `WidgetRunnerConfig` вручную, можно использовать preset builders: + +```python +from pybotx import Bot, IncomingMessage +from pybotx.widgets import ( + ConfirmWidget, + build_production_widget_runner_preset, + on_action, + widget_command, +) +from pybotx.widgets.confirm import CANCEL_ACTION, CONFIRM_ACTION + +widget_preset = build_production_widget_runner_preset( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: "Confirmed", + CANCEL_ACTION: "Cancelled", + }, + ), +) + + +@widget_command(**widget_preset.as_widget_command_kwargs()) +async def confirm_demo_handler(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + return ConfirmWidget( + label="Confirm deployment?", + message=message, + bot=bot, + command="/confirm-demo", + ) +``` + +Что дают builders: + +- `build_widget_runner_observability_preset(...)`: + - включает `LoggingWidgetRunnerObserver` по умолчанию + - умеет включать `PrometheusWidgetRunnerMetricsCollector` + - позволяет добавить свои observers +- `build_production_widget_runner_preset(...)`: + - собирает `WidgetRunnerConfig` + - добавляет observability preset + - возвращает объект с `as_config()`, `as_runner_kwargs()`, `as_widget_command_kwargs()` + +Для метрик доступны готовые collectors: + +- `NoopWidgetRunnerMetricsCollector` +- `InMemoryWidgetRunnerMetricsCollector` +- `PrometheusWidgetRunnerMetricsCollector` + +`PrometheusWidgetRunnerMetricsCollector` публикует: + +- `runs_total` +- `errors_total` +- `skipped_total` +- `result_messages_total` +- `latency_seconds` +- `in_flight` + +## 4. Фабрики хуков + +### 4.1 `on_data_key` + +`on_data_key(key, resolver, should_display=False)`: + +- проверяет `key in widget.message.data` +- если ключа нет -> ничего не делает +- если ключ есть -> вызывает `resolver(widget)` +- `resolver` может вернуть: + - `str` (будет преобразован в `RunnerHookResult`) + - `RunnerHookResult` + - `None` + - awaitable-варианты + +Типичный случай: реагировать только на конкретный ключ callback payload. + +### 4.2 `on_completed` + +`on_completed(is_completed, resolver)`: + +- используется для `after`-хуков +- если `is_completed(widget)` вернул `False` -> результата нет +- если `True` -> отправляется текст из `resolver(widget)` + +Типичный случай: отправить summary после завершения многошагового виджета. + +### 4.3 `on_action` + +`on_action(get_action, actions, default=None, should_display=False)`: + +- вызывает `action = get_action(widget)` (sync/async) +- сопоставляет action с `actions[action]` +- значение в `actions` может быть: + - `str` + - `RunnerHookResult` + - callable `(widget) -> str | RunnerHookResult | None | awaitable` +- если `get_action` бросает `RuntimeError`, хук считается неприменимым +- необязательный `default` обрабатывает неизвестное действие + +Типичный случай: декларативное ветвление по action. + +## 5. Справочник готовых виджетов + + +### 5.1 `ConfirmWidget` + +![img.png](img.png) + +Назначение: + +- бинарное решение (подтвердить/отменить) + +Бизнес-логика (что ты получаешь): + +- Явный пользовательский verdict перед потенциально опасным действием (деплой, удаление, публикация). +- На выходе всегда дискретное решение: `confirm` или `cancel`. +- Решение можно сразу маппить на ветку бизнес-процесса: выполнить операцию или завершить без side effects. + +Конструктор: + +- `label: str` +- `confirm_label: str = "Подтвердить"` +- `cancel_label: str = "Отмена"` +- базовые аргументы виджета (`message`, `bot`, `command`, `additional_markup`, `result_mode`) + +Ключи/константы: + +- `CONFIRM_ACTION_KEY` +- `CONFIRM_ACTION` +- `CANCEL_ACTION` + +Хелперы: + +- `get_action(message) -> str` +- `is_confirmed(message) -> bool` +- `is_cancelled(message) -> bool` + +### 5.2 `ApprovalWidget` + +![img_2.png](img_2.png) + +Назначение: + +- согласование в трех состояниях: approve/reject/comment + +Бизнес-логика (что ты получаешь): + +- Унифицированную точку согласования для процессов, где недостаточно бинарного выбора. +- На выходе одно из действий: `approve`, `reject`, `comment`. +- Прямое ветвление бизнес-процесса: запуск следующего шага, отклонение, запрос дополнительной информации. + +Конструктор: + +- `label: str` +- `approve_label: str = "Согласовать"` +- `reject_label: str = "Отклонить"` +- `comment_label: str = "Комментарий"` +- базовые аргументы виджета + +Константы: + +- `APPROVAL_ACTION_KEY` +- `APPROVE_ACTION` +- `REJECT_ACTION` +- `COMMENT_ACTION` + +Хелперы: + +- `get_action(message) -> str` +- `is_approved(message) -> bool` + +### 5.3 `SelectWidget` + +![img_1.png](img_1.png) + +Назначение: + +- выбор одного значения из списка + +Бизнес-логика (что ты получаешь): + +- Одно нормализованное значение из набора альтернатив (окружение, тариф, тип операции). +- Предсказуемый выход для downstream-логики: строковый `value`. +- Удобный вход для Strategy/роутинга в application-слое. + +Типы: + +- `SelectOption(value: str, label: str)` + +Конструктор: + +- `options: list[str | SelectOption]` +- `label: str` +- `selected_prefix: str = "• "` +- базовые аргументы виджета + +Ключ: + +- `SELECT_VALUE_KEY` + +Хелпер: + +- `get_value(message) -> str` + +### 5.4 `MultiSelectWidget` + +![img_3.png](img_3.png) + +Назначение: + +- множественный выбор с опциональным ограничением + +Бизнес-логика (что ты получаешь): + +- Набор выбранных флагов/опций в одном шаге (каналы уведомлений, разрешения, категории). +- Контроль бизнес-ограничений через `max_selected` (например, не более 2 вариантов). +- Состояние выбора хранится в metadata и устойчиво к последовательным кликам. + +Конструктор: + +- `options: list[str | SelectOption]` +- `label: str` +- `max_selected: int | None = None` +- базовые аргументы виджета + +Ключи data/metadata: + +- `MULTI_SELECT_VALUE_KEY` (кликнутое значение) +- `MULTI_SELECTED_VALUES_KEY` (состояние выбора в metadata) + +Хелпер: + +- `get_selected_values(message) -> list[str]` + +### 5.5 `SearchSelectWidget` + +![img_4.png](img_4.png) + +Назначение: + +- поиск + пагинация + выбор + +Бизнес-логика (что ты получаешь): + +- Выбор одной сущности из большого справочника без перегрузки UI. +- Возможность искать по пользовательскому вводу (`message.argument`/`SEARCH_QUERY_KEY`) и выбрать точный `value`. +- Паттерн для каталога пользователей, сервисов, проектов, заявок. + +Конструктор: + +- `options: list[str | SelectOption]` +- `label: str` +- `page_size: int = 5` +- `empty_label: str = "Ничего не найдено"` +- `selected_prefix: str = "• "` +- базовые аргументы виджета + +Ключи: + +- `SEARCH_QUERY_KEY` +- `SEARCH_PAGE_KEY` +- для финального выбора используется `SELECT_VALUE_KEY` + +Поведение: + +- запрос берется из `message.data[SEARCH_QUERY_KEY]` или из `message.argument` +- фильтрация по `label` и `value` +- текущая страница хранится в callback data + +### 5.6 `FormWizardWidget` + +![img_5.png](img_5.png) + +Назначение: + +- пошаговая форма (next/back/cancel/reset) + +Бизнес-логика (что ты получаешь): + +- Сбор структурированных данных по шагам с явным состоянием процесса. +- На выходе словарь полей `values`, готовый для валидации и сохранения в доменную модель. +- Управляемый сценарий отмены/сброса без потери контроля над состоянием диалога. + +Типы: + +- `FormWizardStep(field: str, label: str)` + +Конструктор: + +- `steps: list[FormWizardStep]` +- `label: str` +- базовые аргументы виджета + +Ключи и действия: + +- `FORM_STEP_INDEX_KEY` +- `FORM_VALUES_KEY` +- `FORM_ACTION_KEY` +- `FORM_INPUT_KEY` +- действия: + `FORM_ACTION_NEXT`, + `FORM_ACTION_BACK`, + `FORM_ACTION_CANCEL`, + `FORM_ACTION_RESET` + +Поля состояния: + +- `values: dict[str, Any]` +- `step_index: int` +- `cancelled: bool` +- `is_completed: bool` +- `is_active: bool` + +Хелпер: + +- `get_values(message) -> dict[str, Any]` + +### 5.7 `DateRangeWidget` + +![img_6.png](img_6.png) + +Назначение: + +- выбор начала и конца периода + reset + +Бизнес-логика (что ты получаешь): + +- Валидный временной интервал (`start`, `end`) как вход для отчетов, аналитики, выборок из БД. +- Снижение ошибок пользователя: диапазон собирается поэтапно, с поддержкой reset. +- На выходе готовый `tuple[date, date]` для application-service. + +Конструктор: + +- `label: str = "Выбор периода"` +- базовые аргументы виджета + +Ключи: + +- `DATE_RANGE_START_KEY` +- `DATE_RANGE_END_KEY` +- `DATE_RANGE_CURSOR_KEY` +- `DATE_RANGE_SELECTED_DATE_KEY` +- `DATE_RANGE_ACTION_KEY` +- действие `DATE_RANGE_ACTION_RESET` + +Поля состояния: + +- `start: date | None` +- `end: date | None` +- `cursor: str` + +Хелпер: + +- `get_value(message) -> tuple[date, date]` + +### 5.8 `TableWidget` + +![img_7.png](img_7.png) + +Назначение: + +- табличный рендер с фильтрацией, сортировкой и пагинацией + +Бизнес-логика (что ты получаешь): + +- Управляемый просмотр набора данных (read-model) прямо в чате. +- Сохранение пользовательского контекста просмотра: фильтр, сортировка, страница. +- Базовый интерфейс для операционных списков (инциденты, заявки, пользователи) без отдельного фронтенда. + +Конструктор: + +- `rows: list[dict[str, Any]]` +- `columns: list[str]` +- `label: str = "Таблица"` +- `page_size: int = 5` +- базовые аргументы виджета + +Ключи: + +- `TABLE_PAGE_KEY` +- `TABLE_SORT_KEY` +- `TABLE_DESC_KEY` +- `TABLE_QUERY_KEY` + +Поведение: + +- query берется из callback data или из `message.argument` +- устойчивое состояние хранится в metadata + +### 5.9 `AsyncJobWidget` + +![img_8.png](img_8.png) + +Назначение: + +- отображение статуса async-задачи и управление действиями refresh/retry/cancel + +Бизнес-логика (что ты получаешь): + +- Единый UX для long-running процессов (импорт, расчет, синхронизация). +- Декларативные пользовательские команды управления жизненным циклом задачи. +- Наглядный статус и контролируемые переходы: queued -> running -> done/failed/cancelled. + +Конструктор: + +- `job_id: str` +- `status: str` +- `details: str = ""` +- базовые аргументы виджета + +Ключи/константы: + +- `ASYNC_JOB_ID_KEY` +- `ASYNC_JOB_STATUS_KEY` +- `ASYNC_JOB_ACTION_KEY` +- статусы: + `ASYNC_JOB_QUEUED`, + `ASYNC_JOB_RUNNING`, + `ASYNC_JOB_DONE`, + `ASYNC_JOB_FAILED`, + `ASYNC_JOB_CANCELLED` +- действия: + `ASYNC_JOB_ACTION_REFRESH`, + `ASYNC_JOB_ACTION_RETRY`, + `ASYNC_JOB_ACTION_CANCEL` + +Хелперы: + +- `get_action(message) -> str` +- `get_status(message) -> str` + +### 5.10 `FileBatchWidget` + +Файл: `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/pybotx/widgets/file_batch.py` + +Назначение: + +- отображение статусов пакетной обработки файлов, пагинация, retry failed + +Бизнес-логика (что ты получаешь): + +- Операционный контроль batch-обработки большого числа файлов. +- Быстрый доступ к конкретному файлу и retry только проблемных элементов. +- Прозрачную картину по пакету: сколько успешно, сколько в процессе, сколько с ошибкой. + +Типы: + +- `BatchFileItem(name: str, status: str, error: str = "")` + +Конструктор: + +- `files: list[BatchFileItem | dict | str] | None` +- `label: str = "Пакет файлов"` +- `page_size: int = 5` +- базовые аргументы виджета + +Ключи/константы: + +- `FILE_BATCH_FILES_KEY` +- `FILE_BATCH_PAGE_KEY` +- `FILE_BATCH_ACTION_KEY` +- `FILE_BATCH_FILE_NAME_KEY` +- действия: + `FILE_BATCH_ACTION_PREV`, + `FILE_BATCH_ACTION_NEXT`, + `FILE_BATCH_ACTION_RETRY_FAILED`, + `FILE_BATCH_ACTION_ITEM` + +Хелперы: + +- `get_action(message) -> str` +- `get_selected_file(message) -> str` + +### 5.11 `CalendarWidget` + +![img_9.png](img_9.png) + +Назначение: + +- календарный пикер месяца с ограничениями периода + +Бизнес-логика (что ты получаешь): + +- Выбор одной даты с ограничениями доменных правил (границы периода, запрет прошлого). +- Нормализованное значение даты для последующих сценариев (бронирование, планирование, запуск по дате). +- Контролируемую навигацию по календарю без ручного парсинга дат из текста. + +Конструктор: + +- `start_date: date | None = None` +- `end_date: date = date.max` +- `include_past: bool = False` +- базовые аргументы виджета + +Ключи: + +- `MONTH_TO_DISPLAY_KEY` +- `SELECTED_DATE_KEY` + +Хелпер: + +- `await get_value(message, bot, send_feedback=True) -> date` + +### 5.12 `CarouselWidget` + +![img_10.png](img_10.png) + +Назначение: + +- горизонтальная/вертикальная листалка с выбором значения + +Бизнес-логика (что ты получаешь): + +- Пошаговый выбор из длинного списка без перегруженного интерфейса. +- На выходе одно выбранное значение, пригодное для прямого использования в бизнес-операции. +- Гибкий UX: циклический/нециклический режим, inline или построчный формат. + +Конструктор: + +- `widget_content: Sequence[Any]` +- `label: str` +- `start_from: int = 0` +- `displayed_content_count: int = 3` +- `control_labels: tuple[str, str] | None = None` +- `inline: bool = True` +- `loop: bool = True` +- `show_numbers: bool = False` +- базовые аргументы виджета + +Ключи/константы: + +- `START_FROM_KEY` +- `SELECTED_VALUE_KEY` +- `SELECTED_VALUE_LABEL_KEY` +- `MESSAGE_LABEL_KEY` +- служебные маркеры управления: + `LEFT_PRESSED`, + `RIGHT_PRESSED` + +Хелпер: + +- `await get_value(message, bot, send_feedback=True) -> str | None` + +### 5.13 `CheckListWidget` + +![img_11.png](img_11.png) + +Назначение: + +- простой чеклист с поддержкой вложенных рядов + +Бизнес-логика (что ты получаешь): + +- Чеклист статусов выполнения этапов процесса (release checklist, onboarding checklist). +- На выходе текущий набор отмеченных пунктов, который можно хранить в metadata/репозитории. +- Простой механизм контроля прогресса без полноценной FSM-формы. + +Конструктор: + +- `widget_content: Sequence[Any]` +- `label: str` +- базовые аргументы виджета + +Ключи: + +- `SELECTED_ITEM_KEY` +- `CHECKED_ITEMS_KEY` + +Хелперы: + +- `get_value(message) -> Any` +- `get_checked_items(message) -> list[Any]` + +### 5.14 `ChecktableWidget` + +![img_12.png](img_12.png) + +Назначение: + +- структурированная таблица полей с чекбоксами и значениями + +Бизнес-логика (что ты получаешь): + +- Компактный интерфейс заполнения/очистки набора полей карточки сущности. +- Явное разделение: заполнено поле или нет (`undefined` vs value). +- Модель для сценариев «быстрой правки» тикета/задачи прямо в чате. + +Типы: + +- `CheckboxContent[T]` с полями: + `label`, `command`, `checkbox_value`, `mapping`, `data` +- поддерживается sentinel `undefined` + +Конструктор: + +- `checkboxes: list[CheckboxContent[Any]]` +- `label: str` +- `uncheck_command: str` +- базовые аргументы виджета + +Примечания: + +- если `checkbox_value` равен `undefined`, поле считается незаполненным +- `mapping` позволяет отображать пользовательские подписи вместо технических значений + +### 5.15 `RangeWidget` + +![img_13.png](img_13.png) + +Назначение: + +- последовательная навигация по набору элементов + +Бизнес-логика (что ты получаешь): + +- Управляемый выбор текущего шага/этапа процесса. +- Явный индекс и текущее значение для application-логики. +- Подходит для сценариев со стадиями: Draft -> Review -> Approved -> Done. + +Конструктор: + +- `elements: Sequence[str] | None = None` +- `label_template: str = "Current element: {element}"` +- `forward_label: str = "Вперед"` +- `backward_label: str = "Назад"` +- `empty_body: str = "Список пуст"` +- базовые аргументы виджета + +Ключи: + +- `CURRENT_INDEX_KEY` +- `ELEMENTS_KEY` + +Хелпер: + +- `get_value(message) -> str` + +### 5.16 `PaginationWidget` + +![img_14.png](img_14.png) + +Назначение: + +- пагинация списка сообщений (`str` или `OutgoingMessage`) в несколько сообщений бота + +Бизнес-логика (что ты получаешь): + +- Читаемую выдачу больших наборов данных без ограничения одним сообщением. +- Переиспользуемую механику «листания» для логов, отчетов, длинных списков результатов. +- Сохранение идентификаторов сообщений для последующего обновления уже выведенных страниц. + +Конструктор: + +- `widget_content: list[OutgoingMessage | str]` +- `paginate_by: int` (`> 0`) +- `delay_between_messages: float = 0.5` (`>= 0`) +- базовые аргументы виджета + +Ключи: + +- `START_FROM_KEY` +- `MESSAGE_IDS_KEY` + +Поведение: + +- первый рендер может отправить несколько сообщений +- переключение страницы обновляет уже отправленные сообщения по ID из metadata +- последнее сообщение содержит управляющие кнопки пагинации + +### 5.17 `MessagesPagerWidget` + +![img_15.png](img_15.png) + +Назначение: + +- упрощенный API над `PaginationWidget` для последовательности строк + +Бизнес-логика (что ты получаешь): + +- Быстрый путь показать пользователю большой список текстовых элементов с пагинацией. +- Минимум кода для cases «вывести N сообщений по шаблону». +- Подходящий слой presentation для логов, уведомлений, истории операций. + +Конструктор: + +- `elements: Sequence[str]` +- `page_size: int` +- `item_template: str = "Element: {element}"` +- `delay_between_messages: float = 0.5` +- базовые аргументы виджета + +## 6. `MessageMarkup` и композиция разметки + +Файл: `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/pybotx/widgets/markup.py` + +`MessageMarkup`: + +- обертка над `BubbleMarkup` + `KeyboardMarkup` +- вспомогательные методы: + `add_bubble(...)`, + `add_keyboard(...)` + +Сценарий использования: + +- основные кнопки виджета формируются внутри класса виджета +- дополнительные глобальные кнопки можно передавать через `additional_markup` +- итоговый markup объединяется в `Widget.add_additional_markup()` + +## 7. Паттерны для команд + +### 7.1 Предпочтительный (декларативный) паттерн + +```python +@collector.command("/select-demo", description="Select demo") +@widget_command( + before=on_data_key( + "select_value", + lambda widget: f"Selected: {widget.message.data['select_value']}", + ), +) +async def select_demo_handler(message: IncomingMessage, bot: Bot) -> SelectWidget: + return SelectWidget( + options=["dev", "prod"], + label="Choose env", + message=message, + bot=bot, + command="/select-demo", + ) +``` + +### 7.2 Замена виджета в хуке (пересборка состояния) + +```python +def retry_failed(widget: FileBatchWidget) -> RunnerHookResult: + updated_files = [...] + replacement = FileBatchWidget( + files=updated_files, + label="Batch status", + message=widget.message, + bot=widget.bot, + command=widget.command, + ) + return RunnerHookResult(widget=replacement, should_display=True) +``` + +### 7.3 Сводка по завершению (`after`) + +```python +@widget_command( + after=on_completed( + lambda widget: widget.is_completed, + lambda widget: f"Done: {widget.values}", + ), +) +async def form_handler(message: IncomingMessage, bot: Bot) -> FormWizardWidget: + ... +``` + +## 8. Как писать собственные виджеты + +### 8.1 Минимальный кастомный виджет + +```python +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage +from pybotx.widgets.base import Widget + +COUNTER_VALUE_KEY = "counter_value" + + +def _safe_int(value: object, default: int = 0) -> int: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return default + + +class CounterWidget(Widget): + def __init__(self, label: str, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + value = _safe_int( + self.message.data.get( + COUNTER_VALUE_KEY, + self.message.metadata.get(COUNTER_VALUE_KEY, 0), + ), + ) + self.value = max(value, 0) + self.widget_metadata[COUNTER_VALUE_KEY] = self.value + self.widget_message.body = f"{label}: {self.value}" + + def add_markup(self) -> None: + self.widget_bubbles.add_button( + command=self.command, + label="+1", + data={COUNTER_VALUE_KEY: self.value + 1}, + ) + if self.value > 0: + self.widget_bubbles.add_button( + command=self.command, + label="-1", + data={COUNTER_VALUE_KEY: self.value - 1}, + new_row=False, + ) +``` + +### 8.2 Команда для кастомного виджета + +```python +from pybotx.widgets import on_data_key, widget_command + + +@collector.command("/counter", description="Counter widget") +@widget_command( + before=on_data_key( + COUNTER_VALUE_KEY, + lambda widget: f"Counter updated: {widget.message.data[COUNTER_VALUE_KEY]}", + should_display=True, + ), +) +async def counter_handler(message: IncomingMessage, bot: Bot) -> CounterWidget: + return CounterWidget( + label="Counter", + message=message, + bot=bot, + command="/counter", + ) +``` + +### 8.3 Правила проектирования собственных виджетов + +1. Держите класс виджета сфокусированным на UI и переходах состояния. +2. Сетевые вызовы/БД/бизнес-решения выносите в сервисный слой до сборки виджета. +3. Всегда валидируйте и нормализуйте `message.data` и `message.metadata`. +4. Не доверяйте типам callback payload. +5. Храните в metadata достаточно данных для восстановления состояния на следующем клике. +6. Для всех ключей callback/metadata используйте явные константы. +7. Для элементов списков предпочитайте неизменяемые структуры (`dataclass(frozen=True)`). +8. Геттеры действий делайте строгими и детерминированными (бросайте `RuntimeError` при невалидном action). + +### 8.4 Идемпотентность и повторная доставка + +Callback-события могут приходить повторно. +Для устойчивого поведения: + +- проектируйте переходы состояния, безопасные при повторе одного и того же события +- внешние side-effect операции защищайте idempotency key (например, `chat_id + source_sync_id + action`) +- отделяйте изменение состояния от внешних эффектов +- не полагайтесь на exactly-once доставку + +## 9. Стратегия тестирования + +### 9.1 Unit-тесты для класса виджета + +Что тестировать: + +- восстановление состояния из metadata в конструкторе +- изменение состояния по callback data +- итоговый `widget_message.body` +- сформированные bubbles/keyboard +- edge-cases: невалидный payload, пустые данные, выход за границы страницы/индекса + +Текущие примеры: + +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/tests/widgets/test_select.py` +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/tests/widgets/test_table.py` +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/tests/widgets/test_pagination.py` + +### 9.2 Тесты runner/декоратора + +Что тестировать: + +- ветки `before` и `after` +- сценарии `should_display=True/False` +- замену виджета через `RunnerHookResult.widget` +- поведение обертки `@widget_command` +- безопасный путь `on_action`, когда action отсутствует (`RuntimeError`) + +Референс: + +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/tests/widgets/test_runner.py` + +### 9.3 Интеграционные тесты (рекомендовано) + +Для production-бота: + +- e2e-поток: команда -> callback -> обновление сообщения +- проверка корректного сохранения metadata между кликами +- проверка обоих режимов результата: `edit` и `message` + +## 10. Диагностика проблем + +### Виджет отправляет новые сообщения вместо редактирования + +Проверьте: + +- у входящего сообщения есть `pybotx_widget = 1` в metadata +- у входящего сообщения есть `source_sync_id` +- путь выполнения идет через `Widget.send_or_update_message` (через `display()` / `send_result()`) + +### `on_action` не срабатывает + +Проверьте: + +- ваш `get_action` действительно бросает `RuntimeError` при отсутствии/невалидности action +- callback data содержит ожидаемый ключ +- строка action совпадает с ключом в `actions` + +### Результат пришел отдельным сообщением, хотя ожидался edit + +Проверьте, какой режим результата выбран: + +- явный `result_mode` в конструкторе виджета +- metadata `pybotx_widget_result_mode` +- значение в аргументе команды (может переопределять режим) + +## 11. Миграция: ручной Runner -> декоратор + +Было: + +```python +runner = WidgetRunner(message, bot) +widget = ConfirmWidget(...) +await runner.run(widget=widget, before=_before, after=_after) +``` + +Стало: + +```python +@widget_command(before=..., after=...) +async def handler(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + return ConfirmWidget(...) +``` + +Преимущества: + +- меньше boilerplate +- более явный и единообразный контракт хендлера +- переиспользуемые хуки +- проще тестировать и поддерживать командой + +## 12. Публичные экспорты (`pybotx.widgets`) + +Ключевые экспорты: + +- все классы виджетов из раздела 5 +- вспомогательные типы: `BatchFileItem`, `CheckboxContent`, `FormWizardStep`, `SelectOption` +- вспомогательные объекты: `undefined`, `Undefined` +- runner API: + `RunnerHookResult`, + `WidgetRunner`, + `widget_command`, + `on_data_key`, + `on_completed`, + `on_action` + +Актуальный список смотрите в: + +- `/Users/aleksandrosovskii/PycharmProjects/pybotx_stable/pybotx/widgets/__init__.py` diff --git a/docs/widgets/img.png b/docs/widgets/img.png new file mode 100644 index 00000000..d28e5ba8 Binary files /dev/null and b/docs/widgets/img.png differ diff --git a/docs/widgets/img_1.png b/docs/widgets/img_1.png new file mode 100644 index 00000000..db9916dc Binary files /dev/null and b/docs/widgets/img_1.png differ diff --git a/docs/widgets/img_10.png b/docs/widgets/img_10.png new file mode 100644 index 00000000..89f45d8c Binary files /dev/null and b/docs/widgets/img_10.png differ diff --git a/docs/widgets/img_11.png b/docs/widgets/img_11.png new file mode 100644 index 00000000..84d4b88e Binary files /dev/null and b/docs/widgets/img_11.png differ diff --git a/docs/widgets/img_12.png b/docs/widgets/img_12.png new file mode 100644 index 00000000..bc77b6e1 Binary files /dev/null and b/docs/widgets/img_12.png differ diff --git a/docs/widgets/img_13.png b/docs/widgets/img_13.png new file mode 100644 index 00000000..7002d1dd Binary files /dev/null and b/docs/widgets/img_13.png differ diff --git a/docs/widgets/img_14.png b/docs/widgets/img_14.png new file mode 100644 index 00000000..da8e35fa Binary files /dev/null and b/docs/widgets/img_14.png differ diff --git a/docs/widgets/img_15.png b/docs/widgets/img_15.png new file mode 100644 index 00000000..63e84c4d Binary files /dev/null and b/docs/widgets/img_15.png differ diff --git a/docs/widgets/img_2.png b/docs/widgets/img_2.png new file mode 100644 index 00000000..b2a83c76 Binary files /dev/null and b/docs/widgets/img_2.png differ diff --git a/docs/widgets/img_3.png b/docs/widgets/img_3.png new file mode 100644 index 00000000..f7f1fe97 Binary files /dev/null and b/docs/widgets/img_3.png differ diff --git a/docs/widgets/img_4.png b/docs/widgets/img_4.png new file mode 100644 index 00000000..c35460d2 Binary files /dev/null and b/docs/widgets/img_4.png differ diff --git a/docs/widgets/img_5.png b/docs/widgets/img_5.png new file mode 100644 index 00000000..e277a94f Binary files /dev/null and b/docs/widgets/img_5.png differ diff --git a/docs/widgets/img_6.png b/docs/widgets/img_6.png new file mode 100644 index 00000000..856acda2 Binary files /dev/null and b/docs/widgets/img_6.png differ diff --git a/docs/widgets/img_7.png b/docs/widgets/img_7.png new file mode 100644 index 00000000..494d9a9e Binary files /dev/null and b/docs/widgets/img_7.png differ diff --git a/docs/widgets/img_8.png b/docs/widgets/img_8.png new file mode 100644 index 00000000..98446488 Binary files /dev/null and b/docs/widgets/img_8.png differ diff --git a/docs/widgets/img_9.png b/docs/widgets/img_9.png new file mode 100644 index 00000000..9ecf3ea2 Binary files /dev/null and b/docs/widgets/img_9.png differ diff --git a/example/__init__.py b/example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/main.py b/example/main.py new file mode 100644 index 00000000..c59a8fe9 --- /dev/null +++ b/example/main.py @@ -0,0 +1,874 @@ +import os +from http import HTTPStatus +from typing import Final +from uuid import UUID + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from pybotx import ( + Bot, + BotAccountWithSecret, + BubbleMarkup, + HandlerCollector, + IncomingMessage, + OutgoingMessage, + build_command_accepted_response, +) +from pybotx.widgets import ( + ApprovalWidget, + AsyncJobWidget, + BatchFileItem, + CalendarWidget, + CarouselWidget, + CheckListWidget, + CheckboxContent, + ChecktableWidget, + ConfirmWidget, + DateRangeWidget, + FileBatchWidget, + FormWizardStep, + FormWizardWidget, + MessageMarkup, + MessagesPagerWidget, + MultiSelectWidget, + PaginationWidget, + RangeWidget, + RunnerHookResult, + SearchSelectWidget, + SelectOption, + SelectWidget, + TableWidget, + on_action, + on_completed, + on_data_key, + widget_command, + undefined, +) +from pybotx.widgets.async_job import ( + ASYNC_JOB_ACTION_CANCEL, + ASYNC_JOB_ACTION_KEY, + ASYNC_JOB_ACTION_REFRESH, + ASYNC_JOB_ACTION_RETRY, + ASYNC_JOB_CANCELLED, + ASYNC_JOB_DONE, + ASYNC_JOB_FAILED, + ASYNC_JOB_ID_KEY, + ASYNC_JOB_QUEUED, + ASYNC_JOB_RUNNING, + ASYNC_JOB_STATUS_KEY, +) +from pybotx.widgets.confirm import ( + CANCEL_ACTION, + CONFIRM_ACTION, +) +from pybotx.widgets.file_batch import ( + FILE_BATCH_ACTION_ITEM, + FILE_BATCH_ACTION_RETRY_FAILED, + FILE_BATCH_FILES_KEY, +) +from pybotx.widgets.calendar import SELECTED_DATE_KEY as CALENDAR_SELECTED_DATE_KEY +from pybotx.widgets.carousel import SELECTED_VALUE_KEY as CAROUSEL_SELECTED_VALUE_KEY +from pybotx.widgets.range import CURRENT_INDEX_KEY +from pybotx.widgets.select import SELECT_VALUE_KEY +from pybotx.widgets.base import ( + WIDGET_RESULT_MODE_EDIT, + WIDGET_RESULT_MODE_MESSAGE, + WidgetResultMode, +) + +MULTI_SELECT_DONE_KEY: Final[str] = "multi_select_done" +CHECKTABLE_STATE_KEY: Final[str] = "checktable_state" +CHECKTABLE_FIELD_KEY: Final[str] = "field" +CHECKTABLE_SET_COMMAND: Final[str] = "/_checktable-set" +CHECKTABLE_CLEAR_COMMAND: Final[str] = "/_checktable-clear" + +WIDGET_COMMANDS_TEXT: Final[str] = "\n".join( + [ + "Widget demos (new):", + "/confirm-demo", + "/select-demo", + "/multi-select-demo", + "/search-select-demo [query]", + "/form-demo [text]", + "/date-range-demo", + "/table-demo [query]", + "/approval-demo", + "/async-job-demo [queued|running|failed|done]", + "/file-batch-demo", + "", + "Widget demos (from pybotx-widgets):", + "/calendar-demo", + "/carousel-demo", + "/checklist-demo", + "/checktable-demo", + "/range-demo", + "/pagination-demo", + "/messages-pager-demo", + ], +) + +DEFAULT_DEMO_RESULT_MODE: Final[WidgetResultMode] = WIDGET_RESULT_MODE_EDIT +DEMO_RESULT_MODE_BY_COMMAND: Final[dict[str, WidgetResultMode]] = { + # Example: + # "/confirm-demo": WIDGET_RESULT_MODE_MESSAGE, +} + +SELECT_OPTIONS: Final[list[str | SelectOption]] = [ + SelectOption(value="dev", label="Development"), + SelectOption(value="stage", label="Staging"), + SelectOption(value="prod", label="Production"), +] + +SEARCH_OPTIONS: Final[list[str | SelectOption]] = [ + SelectOption(value="alice", label="Alice Smith"), + SelectOption(value="alex", label="Alex Johnson"), + SelectOption(value="bob", label="Bob Lee"), + SelectOption(value="chris", label="Chris Kim"), + SelectOption(value="diana", label="Diana Ford"), + SelectOption(value="elena", label="Elena Morgan"), +] + +FORM_STEPS: Final[list[FormWizardStep]] = [ + FormWizardStep(field="name", label="Enter your name"), + FormWizardStep(field="email", label="Enter your email"), + FormWizardStep(field="team", label="Enter your team"), +] + +TABLE_ROWS: Final[list[dict[str, str]]] = [ + {"name": "Alice", "team": "Platform", "role": "Engineer"}, + {"name": "Bob", "team": "Platform", "role": "Team Lead"}, + {"name": "Chris", "team": "QA", "role": "Engineer"}, + {"name": "Diana", "team": "Security", "role": "Engineer"}, + {"name": "Elena", "team": "Analytics", "role": "Analyst"}, +] + +DEFAULT_BATCH_FILES: Final[list[BatchFileItem]] = [ + BatchFileItem(name="sales_2026_01.csv", status="done"), + BatchFileItem(name="sales_2026_02.csv", status="failed", error="bad header"), + BatchFileItem(name="sales_2026_03.csv", status="running"), + BatchFileItem(name="sales_2026_04.csv", status="pending"), + BatchFileItem(name="sales_2026_05.csv", status="failed", error="timeout"), +] + +ALLOWED_ASYNC_STATUSES: Final[set[str]] = { + ASYNC_JOB_QUEUED, + ASYNC_JOB_RUNNING, + ASYNC_JOB_DONE, + ASYNC_JOB_FAILED, + ASYNC_JOB_CANCELLED, +} + +CHECKTABLE_OWNER_VALUES: Final[list[str]] = ["Alice", "Bob", "Diana"] +CHECKTABLE_PRIORITY_LABELS: Final[dict[str, str]] = { + "low": "Low", + "medium": "Medium", + "high": "High", +} +CHECKTABLE_NOTES_VALUES: Final[list[str]] = [ + "Needs product approval", + "No additional notes", +] + +PAGINATION_ITEMS: Final[list[str]] = [ + "Log line #1: accepted", + "Log line #2: validated", + "Log line #3: transformed", + "Log line #4: uploaded", + "Log line #5: indexed", + "Log line #6: completed", +] + +MESSAGES_PAGER_ITEMS: Final[list[str]] = [ + "alpha", + "beta", + "gamma", + "delta", + "epsilon", +] + + +def _multi_select_done_result(widget: MultiSelectWidget) -> str | None: + if not widget.message.data.get(MULTI_SELECT_DONE_KEY): + return None + + selected_items_list = MultiSelectWidget.get_selected_values(widget.message) + selected_items = ", ".join(selected_items_list) or "nothing" + return f"Selected values: {selected_items}" + + +def _form_after(widget: FormWizardWidget) -> str | None: + if widget.is_completed: + values_text = ", ".join(f"{key}={value}" for key, value in widget.values.items()) + return f"Form completed: {values_text}" + + if widget.cancelled: + return "Form cancelled. Click reset to start over." + + return None + + +def _is_date_range_completed(widget: DateRangeWidget) -> bool: + return widget.start is not None and widget.end is not None + + +def _date_range_result(widget: DateRangeWidget) -> str: + assert widget.start is not None + assert widget.end is not None + return f"Date range selected: {widget.start.isoformat()} .. {widget.end.isoformat()}" + + +def _build_file_batch_widget( + message: IncomingMessage, + bot: Bot, + files: list[BatchFileItem], +) -> FileBatchWidget: + widget_files: list[BatchFileItem | dict[str, object] | str] = [ + file_item for file_item in files + ] + return FileBatchWidget( + files=widget_files, + label="Batch import status", + page_size=3, + message=message, + bot=bot, + command="/file-batch-demo", + result_mode=_demo_result_mode("/file-batch-demo"), + ) + + +def _file_batch_retry_hook(widget: FileBatchWidget) -> RunnerHookResult: + retried_files = _retry_failed_files(widget.files) + return RunnerHookResult( + widget=_build_file_batch_widget(widget.message, widget.bot, retried_files), + should_display=True, + ) + + +async def _calendar_selected_result(widget: CalendarWidget) -> str: + selected_date = await CalendarWidget.get_value( + widget.message, + widget.bot, + send_feedback=False, + ) + return f"Selected date: {selected_date.isoformat()}" + + +async def _carousel_selected_result(widget: CarouselWidget) -> str | None: + selected_value = await CarouselWidget.get_value( + widget.message, + widget.bot, + send_feedback=False, + ) + if selected_value is None: + return None + + return f"Carousel selected: {selected_value}" + + +def _range_after(widget: RangeWidget) -> str | None: + if CURRENT_INDEX_KEY not in widget.message.data: + return None + + if widget.result_mode != WIDGET_RESULT_MODE_MESSAGE: + return None + + current_value = RangeWidget.get_value(widget.message) + return f"Current stage: {current_value}" + + +def _build_checktable_widget( + message: IncomingMessage, + bot: Bot, + state: dict[str, str], +) -> ChecktableWidget: + message.metadata[CHECKTABLE_STATE_KEY] = dict(state) + + checkboxes: list[CheckboxContent[str]] = [ + CheckboxContent[str]( + label="Owner", + command=CHECKTABLE_SET_COMMAND, + checkbox_value=state.get("owner", undefined), + data={CHECKTABLE_FIELD_KEY: "owner"}, + ), + CheckboxContent[str]( + label="Priority", + command=CHECKTABLE_SET_COMMAND, + checkbox_value=state.get("priority", undefined), + mapping=CHECKTABLE_PRIORITY_LABELS, + data={CHECKTABLE_FIELD_KEY: "priority"}, + ), + CheckboxContent[str]( + label="Notes", + command=CHECKTABLE_SET_COMMAND, + checkbox_value=state.get("notes", undefined), + data={CHECKTABLE_FIELD_KEY: "notes"}, + ), + ] + + return ChecktableWidget( + checkboxes=checkboxes, + label="Ticket fields", + uncheck_command=CHECKTABLE_CLEAR_COMMAND, + message=message, + bot=bot, + command="/checktable-demo", + result_mode=_demo_result_mode("/checktable-demo"), + ) + + +collector = HandlerCollector() + + +@collector.command("/widgets", description="List all widget demo commands") +async def widgets_handler(_: IncomingMessage, bot: Bot) -> None: + await bot.answer_message(WIDGET_COMMANDS_TEXT) + + +@collector.command("/confirm-demo", description="Confirm widget demo") +@widget_command( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: "Confirm result: confirmed", + CANCEL_ACTION: "Confirm result: cancelled", + }, + ), +) +async def confirm_demo_handler(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + return ConfirmWidget( + label="Confirm deployment to production?", + message=message, + bot=bot, + command="/confirm-demo", + result_mode=_demo_result_mode("/confirm-demo"), + ) + + +@collector.command("/select-demo", description="Single select widget demo") +@widget_command( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: ( + f"Selected environment: {SelectWidget.get_value(widget.message)}" + ), + ), +) +async def select_demo_handler(message: IncomingMessage, bot: Bot) -> SelectWidget: + return SelectWidget( + options=SELECT_OPTIONS, + label="Choose an environment", + message=message, + bot=bot, + command="/select-demo", + result_mode=_demo_result_mode("/select-demo"), + ) + + +@collector.command("/multi-select-demo", description="Multi select widget demo") +@widget_command( + before=on_data_key( + MULTI_SELECT_DONE_KEY, + _multi_select_done_result, + ), +) +async def multi_select_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> MultiSelectWidget: + summary_bubbles = BubbleMarkup() + summary_bubbles.add_button( + command="/multi-select-demo", + label="Finish selection", + data={MULTI_SELECT_DONE_KEY: True}, + ) + + return MultiSelectWidget( + options=[ + SelectOption(value="email", label="Email notifications"), + SelectOption(value="sms", label="SMS notifications"), + SelectOption(value="push", label="Push notifications"), + ], + label="Choose up to 2 notification channels", + max_selected=2, + message=message, + bot=bot, + command="/multi-select-demo", + additional_markup=MessageMarkup(bubbles=summary_bubbles), + result_mode=_demo_result_mode("/multi-select-demo"), + ) + + +@collector.command("/search-select-demo", description="Search + select widget demo") +@widget_command( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: f"Selected user: {SelectWidget.get_value(widget.message)}", + ), +) +async def search_select_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> SearchSelectWidget: + return SearchSelectWidget( + options=SEARCH_OPTIONS, + label="Search user by name", + page_size=3, + message=message, + bot=bot, + command="/search-select-demo", + result_mode=_demo_result_mode("/search-select-demo"), + ) + + +@collector.command("/form-demo", description="Form wizard widget demo") +@widget_command(after=_form_after) +async def form_demo_handler(message: IncomingMessage, bot: Bot) -> FormWizardWidget: + return FormWizardWidget( + steps=FORM_STEPS, + label="Profile form", + message=message, + bot=bot, + command="/form-demo", + result_mode=_demo_result_mode("/form-demo"), + ) + + +@collector.command("/date-range-demo", description="Date range widget demo") +@widget_command( + after=on_completed(_is_date_range_completed, _date_range_result), +) +async def date_range_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> DateRangeWidget: + return DateRangeWidget( + label="Pick start and end dates", + message=message, + bot=bot, + command="/date-range-demo", + result_mode=_demo_result_mode("/date-range-demo"), + ) + + +@collector.command("/table-demo", description="Table widget demo") +@widget_command +async def table_demo_handler(message: IncomingMessage, bot: Bot) -> TableWidget: + return TableWidget( + rows=TABLE_ROWS, + columns=["name", "team", "role"], + label="Team directory", + page_size=2, + message=message, + bot=bot, + command="/table-demo", + result_mode=_demo_result_mode("/table-demo"), + ) + + +@collector.command("/approval-demo", description="Approval widget demo") +@widget_command( + before=on_action( + lambda widget: ApprovalWidget.get_action(widget.message), + { + "approve": "Approval action: approve", + "reject": "Approval action: reject", + "comment": "Approval action: comment", + }, + ), +) +async def approval_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> ApprovalWidget: + return ApprovalWidget( + label="Approve release candidate #42?", + message=message, + bot=bot, + command="/approval-demo", + result_mode=_demo_result_mode("/approval-demo"), + ) + + +@collector.command("/async-job-demo", description="Async job widget demo") +@widget_command +async def async_job_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> AsyncJobWidget: + status, details = _resolve_async_job_state(message) + + return AsyncJobWidget( + job_id=_resolve_job_id(message), + status=status, + details=details, + message=message, + bot=bot, + command="/async-job-demo", + result_mode=_demo_result_mode("/async-job-demo"), + ) + + +@collector.command("/file-batch-demo", description="File batch widget demo") +@widget_command( + before=on_action( + lambda widget: FileBatchWidget.get_action(widget.message), + { + FILE_BATCH_ACTION_ITEM: ( + lambda widget: ( + f"Selected file: {FileBatchWidget.get_selected_file(widget.message)}" + ) + ), + FILE_BATCH_ACTION_RETRY_FAILED: _file_batch_retry_hook, + }, + ), +) +async def file_batch_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> FileBatchWidget: + initial_files = _load_batch_files(message) + return _build_file_batch_widget(message, bot, initial_files) + + +@collector.command("/calendar-demo", description="Calendar widget demo") +@widget_command( + before=on_data_key( + CALENDAR_SELECTED_DATE_KEY, + _calendar_selected_result, + ), +) +async def calendar_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> CalendarWidget: + return CalendarWidget( + include_past=True, + message=message, + bot=bot, + command="/calendar-demo", + result_mode=_demo_result_mode("/calendar-demo"), + ) + + +@collector.command("/carousel-demo", description="Carousel widget demo") +@widget_command( + before=on_data_key( + CAROUSEL_SELECTED_VALUE_KEY, + _carousel_selected_result, + ), +) +async def carousel_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> CarouselWidget: + return CarouselWidget( + widget_content=["Paris", "Berlin", "Prague", "Rome", "Madrid", "Lisbon"], + label="Choose a city", + displayed_content_count=3, + loop=False, + inline=True, + message=message, + bot=bot, + command="/carousel-demo", + result_mode=_demo_result_mode("/carousel-demo"), + ) + + +@collector.command("/checklist-demo", description="Checklist widget demo") +@widget_command +async def checklist_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> CheckListWidget: + checked_before = CheckListWidget.get_checked_items(message) + widget = CheckListWidget( + widget_content=["Build", ["Deploy", "Verify"], "Notify"], + label=f"Release checklist (checked: {len(checked_before)})", + message=message, + bot=bot, + command="/checklist-demo", + result_mode=_demo_result_mode("/checklist-demo"), + ) + widget.widget_message.body = f"Release checklist (checked: {len(widget.checked_items)})" + return widget + + +@collector.command("/checktable-demo", description="Checktable widget demo") +@widget_command +async def checktable_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> ChecktableWidget: + state = _read_checktable_state(message) + return _build_checktable_widget(message, bot, state) + + +@collector.command(CHECKTABLE_SET_COMMAND, visible=False) +@widget_command +async def checktable_set_handler( + message: IncomingMessage, + bot: Bot, +) -> ChecktableWidget: + state = _read_checktable_state(message) + field = str(message.data.get(CHECKTABLE_FIELD_KEY, "")) + + if field == "owner": + state[field] = _next_value(state.get(field), CHECKTABLE_OWNER_VALUES) + elif field == "priority": + priority_keys = list(CHECKTABLE_PRIORITY_LABELS) + state[field] = _next_value(state.get(field), priority_keys) + elif field == "notes": + state[field] = _next_value(state.get(field), CHECKTABLE_NOTES_VALUES) + + return _build_checktable_widget(message, bot, state) + + +@collector.command(CHECKTABLE_CLEAR_COMMAND, visible=False) +@widget_command +async def checktable_clear_handler( + message: IncomingMessage, + bot: Bot, +) -> ChecktableWidget: + state = _read_checktable_state(message) + field = str(message.data.get(CHECKTABLE_FIELD_KEY, "")) + state.pop(field, None) + return _build_checktable_widget(message, bot, state) + + +@collector.command("/range-demo", description="Range widget demo") +@widget_command(after=_range_after) +async def range_demo_handler(message: IncomingMessage, bot: Bot) -> RangeWidget: + return RangeWidget( + elements=["Draft", "Review", "Approved", "Done"], + label_template="Current stage {index}/{total}: {element}", + message=message, + bot=bot, + command="/range-demo", + result_mode=_demo_result_mode("/range-demo"), + ) + + +@collector.command("/pagination-demo", description="Pagination widget demo") +@widget_command +async def pagination_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> PaginationWidget: + widget_content: list[OutgoingMessage | str] = [item for item in PAGINATION_ITEMS] + return PaginationWidget( + widget_content=widget_content, + paginate_by=3, + delay_between_messages=0, + message=message, + bot=bot, + command="/pagination-demo", + result_mode=_demo_result_mode("/pagination-demo"), + ) + + +@collector.command("/messages-pager-demo", description="Messages pager widget demo") +@widget_command +async def messages_pager_demo_handler( + message: IncomingMessage, + bot: Bot, +) -> MessagesPagerWidget: + return MessagesPagerWidget( + elements=MESSAGES_PAGER_ITEMS, + page_size=2, + item_template="Message {index}/{total}: {element}", + delay_between_messages=0, + message=message, + bot=bot, + command="/messages-pager-demo", + result_mode=_demo_result_mode("/messages-pager-demo"), + ) + + +@collector.default_message_handler +async def default_message_handler(_: IncomingMessage, bot: Bot) -> None: + await bot.answer_message("Unknown command. Use /widgets to open demos.") + + +def _demo_result_mode(command: str) -> WidgetResultMode: + return DEMO_RESULT_MODE_BY_COMMAND.get(command, DEFAULT_DEMO_RESULT_MODE) + + +def _read_checktable_state(message: IncomingMessage) -> dict[str, str]: + raw_state = message.metadata.get(CHECKTABLE_STATE_KEY) + if not isinstance(raw_state, dict): + return {} + + state: dict[str, str] = {} + for field in ("owner", "priority", "notes"): + value = raw_state.get(field) + if isinstance(value, str) and value: + state[field] = value + + return state + + +def _next_value(current: str | None, values: list[str]) -> str: + if current not in values: + return values[0] + + current_index = values.index(current) + next_index = (current_index + 1) % len(values) + return values[next_index] + +def _require_env(name: str) -> str: + value = os.getenv(name) + if value: + return value + + raise RuntimeError(f"Environment variable {name} is required") + + +def _resolve_job_id(message: IncomingMessage) -> str: + metadata_value = message.metadata.get(ASYNC_JOB_ID_KEY) + if isinstance(metadata_value, str) and metadata_value: + return metadata_value + + return "job-42" + + +def _resolve_async_job_state(message: IncomingMessage) -> tuple[str, str]: + status = _resolve_initial_async_status(message) + + if ASYNC_JOB_ACTION_KEY not in message.data: + return status, "Use widget buttons to move the async job state." + + action = AsyncJobWidget.get_action(message) + + if action == ASYNC_JOB_ACTION_CANCEL: + return ASYNC_JOB_CANCELLED, "Job cancelled by user." + + if action == ASYNC_JOB_ACTION_RETRY: + return ASYNC_JOB_QUEUED, "Retry requested." + + if action != ASYNC_JOB_ACTION_REFRESH: + return status, "Unknown action." + + if status == ASYNC_JOB_QUEUED: + return ASYNC_JOB_RUNNING, "Job picked by worker." + + if status == ASYNC_JOB_RUNNING: + return ASYNC_JOB_DONE, "Job finished successfully." + + if status == ASYNC_JOB_FAILED: + return ASYNC_JOB_FAILED, "Job still failed, click Retry." + + if status == ASYNC_JOB_CANCELLED: + return ASYNC_JOB_CANCELLED, "Job is already cancelled." + + return ASYNC_JOB_DONE, "Job already done." + + +def _resolve_initial_async_status(message: IncomingMessage) -> str: + raw_status = message.metadata.get(ASYNC_JOB_STATUS_KEY) + if isinstance(raw_status, str) and raw_status in ALLOWED_ASYNC_STATUSES: + return raw_status + + argument = message.argument.strip().lower() + if argument in ALLOWED_ASYNC_STATUSES: + return argument + + return ASYNC_JOB_QUEUED + + +def _load_batch_files(message: IncomingMessage) -> list[BatchFileItem]: + raw_files = message.metadata.get(FILE_BATCH_FILES_KEY) + if not isinstance(raw_files, list): + return list(DEFAULT_BATCH_FILES) + + files: list[BatchFileItem] = [] + for raw_item in raw_files: + if not isinstance(raw_item, dict): + continue + + name = raw_item.get("name") + status = raw_item.get("status") + if name is None or status is None: + continue + + files.append( + BatchFileItem( + name=str(name), + status=str(status), + error=str(raw_item.get("error", "")), + ), + ) + + if not files: + return list(DEFAULT_BATCH_FILES) + + return files + + +def _retry_failed_files(files: list[BatchFileItem]) -> list[BatchFileItem]: + retried_files: list[BatchFileItem] = [] + + for file_item in files: + if file_item.status in {"failed", "error"}: + retried_files.append( + BatchFileItem( + name=file_item.name, + status="running", + error="", + ), + ) + continue + + retried_files.append(file_item) + + return retried_files + + +def _build_bot_from_env() -> Bot: + account = BotAccountWithSecret( + + ) + return Bot( + collectors=[collector], + bot_accounts=[account], + ) + + +bot = _build_bot_from_env() + +app = FastAPI() +app.add_event_handler("startup", bot.startup) +app.add_event_handler("shutdown", bot.shutdown) + + +@app.post("/command") +async def command_handler(request: Request) -> JSONResponse: + bot.async_execute_raw_bot_command( + await request.json(), + request_headers=request.headers, + ) + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + +@app.get("/status") +async def status_handler(request: Request) -> JSONResponse: + status = await bot.raw_get_status( + dict(request.query_params), + request_headers=request.headers, + ) + return JSONResponse(status) + + +@app.post("/notification/callback") +async def callback_handler(request: Request) -> JSONResponse: + await bot.set_raw_botx_method_result( + await request.json(), + verify_request=False, + ) + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) diff --git a/poetry.lock b/poetry.lock index d56cb4b5..fb97b8f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -82,25 +82,13 @@ files = [ {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] -[[package]] -name = "annotated-doc" -version = "0.0.4" -description = "Document parameters, class attributes, return types, and variables inline, with Annotated." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, - {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, -] - [[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", "dev"] +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"}, @@ -112,7 +100,7 @@ version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, @@ -126,338 +114,30 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "bandit" -version = "1.9.3" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a"}, - {file = "bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] +groups = ["main"] files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] -[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", "dev"] +groups = ["main"] +markers = "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"}, ] -markers = {main = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} - -[[package]] -name = "coverage" -version = "7.13.3" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0"}, - {file = "coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6"}, - {file = "coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f"}, - {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a"}, - {file = "coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be"}, - {file = "coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73"}, - {file = "coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b"}, - {file = "coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad"}, - {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222"}, - {file = "coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb"}, - {file = "coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301"}, - {file = "coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595"}, - {file = "coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34"}, - {file = "coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7"}, - {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0"}, - {file = "coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1"}, - {file = "coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d"}, - {file = "coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25"}, - {file = "coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1"}, - {file = "coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67"}, - {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86"}, - {file = "coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43"}, - {file = "coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587"}, - {file = "coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9"}, - {file = "coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3"}, - {file = "coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e"}, - {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96"}, - {file = "coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f"}, - {file = "coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c"}, - {file = "coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b"}, - {file = "coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4"}, - {file = "coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c"}, - {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a"}, - {file = "coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4"}, - {file = "coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0"}, - {file = "coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8"}, - {file = "coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508"}, - {file = "coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e"}, - {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024"}, - {file = "coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3"}, - {file = "coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8"}, - {file = "coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3"}, - {file = "coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910"}, - {file = "coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "deepdiff" -version = "8.6.1" -description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b"}, - {file = "deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a"}, -] - -[package.dependencies] -orderly-set = ">=5.4.1,<6" - -[package.extras] -cli = ["click (>=8.1.0,<8.2.0)", "pyyaml (>=6.0.0,<6.1.0)"] -coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)", "uuid6 (==2025.0.1)"] -docs = ["Sphinx (>=6.2.0,<6.3.0)", "sphinx-sitemap (>=2.6.0,<2.7.0)", "sphinxemoji (>=0.3.0,<0.4.0)"] -optimize = ["orjson"] -static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)", "pydantic (>=2.10.0,<2.11.0)"] -test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"] [[package]] name = "exceptiongroup" @@ -465,7 +145,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -478,88 +158,13 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "execnet" -version = "2.1.2" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, - {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "factory-boy" -version = "3.3.3" -description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc"}, - {file = "factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03"}, -] - -[package.dependencies] -Faker = ">=0.7.0" - -[package.extras] -dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "mongomock", "mypy", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] -doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] - -[[package]] -name = "faker" -version = "40.1.2" -description = "Faker is a Python package that generates fake data for you." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42"}, - {file = "faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30"}, -] - -[package.dependencies] -tzdata = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -tzdata = ["tzdata"] - -[[package]] -name = "fastapi" -version = "0.128.1" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "fastapi-0.128.1-py3-none-any.whl", hash = "sha256:ee82146bbf91ea5bbf2bb8629e4c6e056c4fbd997ea6068501b11b15260b50fb"}, - {file = "fastapi-0.128.1.tar.gz", hash = "sha256:ce5be4fa26d4ce6f54debcc873d1fb8e0e248f5c48d7502ba6c61457ab2dc766"}, -] - -[package.dependencies] -annotated-doc = ">=0.0.2" -pydantic = ">=2.7.0" -starlette = ">=0.40.0,<0.51.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)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "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)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] - [[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", "dev"] +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"}, @@ -571,7 +176,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +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"}, @@ -593,7 +198,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +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"}, @@ -612,47 +217,13 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "hypothesis" -version = "6.151.5" -description = "The property-based testing library for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13"}, - {file = "hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} -sortedcontainers = ">=2.1.0,<3.0.0" - -[package.extras] -all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.102)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] -cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] -codemods = ["libcst (>=0.3.16)"] -crosshair = ["crosshair-tool (>=0.0.102)", "hypothesis-crosshair (>=0.0.27)"] -dateutil = ["python-dateutil (>=1.4)"] -django = ["django (>=4.2)"] -dpcontracts = ["dpcontracts (>=0.4)"] -ghostwriter = ["black (>=20.8b0)"] -lark = ["lark (>=0.10.1)"] -numpy = ["numpy (>=1.21.6)"] -pandas = ["pandas (>=1.1)"] -pytest = ["pytest (>=4.6)"] -pytz = ["pytz (>=2014.1)"] -redis = ["redis (>=3.0.0)"] -watchdog = ["watchdog (>=4.0.0)"] -zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] - [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -661,105 +232,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "librt" -version = "0.7.8" -description = "Mypyc runtime library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d"}, - {file = "librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b"}, - {file = "librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d"}, - {file = "librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d"}, - {file = "librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c"}, - {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c"}, - {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d"}, - {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0"}, - {file = "librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85"}, - {file = "librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c"}, - {file = "librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f"}, - {file = "librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac"}, - {file = "librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c"}, - {file = "librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8"}, - {file = "librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff"}, - {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3"}, - {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75"}, - {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873"}, - {file = "librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7"}, - {file = "librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c"}, - {file = "librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232"}, - {file = "librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63"}, - {file = "librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93"}, - {file = "librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592"}, - {file = "librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850"}, - {file = "librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62"}, - {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b"}, - {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714"}, - {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449"}, - {file = "librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac"}, - {file = "librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708"}, - {file = "librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0"}, - {file = "librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc"}, - {file = "librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2"}, - {file = "librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3"}, - {file = "librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6"}, - {file = "librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d"}, - {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e"}, - {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca"}, - {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93"}, - {file = "librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951"}, - {file = "librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34"}, - {file = "librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09"}, - {file = "librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418"}, - {file = "librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611"}, - {file = "librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758"}, - {file = "librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea"}, - {file = "librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac"}, - {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398"}, - {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81"}, - {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83"}, - {file = "librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d"}, - {file = "librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44"}, - {file = "librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce"}, - {file = "librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f"}, - {file = "librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde"}, - {file = "librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e"}, - {file = "librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b"}, - {file = "librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666"}, - {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581"}, - {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a"}, - {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca"}, - {file = "librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365"}, - {file = "librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32"}, - {file = "librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06"}, - {file = "librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6"}, - {file = "librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b"}, - {file = "librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c"}, - {file = "librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5"}, - {file = "librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71"}, - {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e"}, - {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63"}, - {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94"}, - {file = "librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb"}, - {file = "librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be"}, - {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"}, -] - [[package]] name = "loguru" version = "0.7.3" @@ -779,188 +251,25 @@ 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 = "markdown-it-py" -version = "4.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mypy" -version = "1.19.1" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, - {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, - {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, - {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, - {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, - {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, - {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, - {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, - {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, - {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, - {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, - {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, - {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, - {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, - {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, - {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, -] - -[package.dependencies] -librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} -mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - [[package]] name = "mypy-extensions" version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] -[[package]] -name = "orderly-set" -version = "5.5.0" -description = "Orderly set" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7"}, - {file = "orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce"}, -] - -[package.extras] -coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)"] -optimize = ["orjson"] -static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)"] -test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"] - -[[package]] -name = "packaging" -version = "26.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, -] - -[[package]] -name = "pathspec" -version = "1.0.4" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - [[package]] name = "pydantic" version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -982,7 +291,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -1110,21 +419,6 @@ files = [ [package.dependencies] typing-extensions = ">=4.14.1" -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - [[package]] name = "pyjwt" version = "2.11.0" @@ -1144,373 +438,20 @@ docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] -name = "pytest" -version = "9.0.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -description = "Pytest support for asyncio" +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" optional = false python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, -] - -[package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, -] - -[package.dependencies] -coverage = {version = ">=7.10.6", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=7" - -[package.extras] -testing = ["process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pytest-timeout" -version = "2.4.0" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, - {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, - {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, -] - -[package.dependencies] -execnet = ">=2.1" -pytest = ">=7.0.0" - -[package.extras] -psutil = ["psutil (>=3.0)"] -setproctitle = ["setproctitle"] -testing = ["filelock"] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "respx" -version = "0.22.0" -description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, - {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, -] - -[package.dependencies] -httpx = ">=0.25.0" - -[[package]] -name = "rich" -version = "14.3.2" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, - {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "ruff" -version = "0.15.0" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455"}, - {file = "ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d"}, - {file = "ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4"}, - {file = "ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e"}, - {file = "ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662"}, - {file = "ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1"}, - {file = "ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16"}, - {file = "ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3"}, - {file = "ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3"}, - {file = "ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18"}, - {file = "ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a"}, - {file = "ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a"}, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, - {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, -] - -[[package]] -name = "starlette" -version = "0.50.0" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.10" -groups = ["dev"] +groups = ["main"] files = [ - {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, - {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, ] -[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 = "stevedore" -version = "5.6.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, - {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, -] - -[[package]] -name = "tomli" -version = "2.4.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_full_version <= \"3.11.0a6\"" -files = [ - {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, - {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, - {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, - {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, - {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, - {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, - {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, - {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, - {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, - {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, - {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, - {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, - {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, - {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, - {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, - {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, - {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, - {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, - {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, -] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "typing-extensions" @@ -1518,7 +459,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +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"}, @@ -1530,7 +471,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +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"}, @@ -1539,57 +480,6 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" -[[package]] -name = "tzdata" -version = "2025.3" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["dev"] -markers = "platform_system == \"Windows\"" -files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uvicorn" -version = "0.40.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, - {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=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" @@ -1609,4 +499,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "bffefac37e96349773d527aef2490cbf8737e1935f70e05528ac23cc3c8294f3" +content-hash = "7709cd52f2c9893fb4002c782ec424978e2dd90e0143a4c2ad417ad315ddb669" diff --git a/pybotx/__init__.py b/pybotx/__init__.py index fa50a5c4..197f8ee5 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -15,9 +15,17 @@ ) from pybotx.auth import BotXAuthVersion from pybotx.bot.bot import Bot +from pybotx.bot.command_processing import ( + BotCommandOverloadAction, + BotCommandOverloadStrategy, + BotCommandProcessingConfig, + DropOldestBotCommandOverloadStrategy, + RejectNewBotCommandOverloadStrategy, +) from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto from pybotx.bot.exceptions import ( AnswerDestinationLookupError, + BotCommandRejectedError, BotShuttingDownError, BotXMethodCallbackNotFoundError, RequestHeadersNotProvidedError, @@ -30,6 +38,23 @@ SyncSmartAppEventHandlerFunc, ) from pybotx.bot.handler_collector import HandlerCollector +from pybotx.bot.healthcheck import ( + HealthStatus, + HealthcheckService, + ReadinessCheck, + ReadinessCheckResult, + build_healthcheck_router, + setup_healthcheck, +) +from pybotx.bot.ingress_observability import ( + BotIngressMetricsCollector, + InMemoryIngressMetricsCollector, + IngressCommandMetadata, + IngressCommandResult, + IngressMetricsSnapshot, + NoopIngressMetricsCollector, + PrometheusIngressMetricsCollector, +) from pybotx.bot.testing import lifespan_wrapper from pybotx.client.exceptions.callbacks import ( BotXMethodFailedCallbackReceivedError, @@ -65,6 +90,45 @@ StealthModeDisabledError, ) from pybotx.client.exceptions.users import UserNotFoundError +from pybotx.client.http_config import ( + AnyOfBotXRetryRequestPolicy, + BotXOperation, + BotXRetryPolicy, + BotXRetryRequestPolicy, + BotXRetryStrategy, + KnownSafeBotXRetryRequestPolicy, + OperationNameAllowlistBotXRetryRequestPolicy, + PathAllowlistBotXRetryRequestPolicy, + RetryAllBotXRequestsPolicy, + SafeBotXRetryRequestPolicy, + TenacityRetryStrategy, + build_default_httpx_limits, + build_default_httpx_timeout, +) +from pybotx.client.observability import ( + BotXRequestMetadata, + BotXRequestObserver, + BotXRequestResult, + BotXRetryEvent, + OpenTelemetryTracingCollector, + PathNormalizer, + PrometheusMetricsCollector, + ReasonNormalizer, + SpanEnricher, +) +from pybotx.presets import ( + BotObservabilityPreset, + BotProductionPreset, + BotRetryPreset, + build_production_bot_preset, + build_production_observability_preset, + build_production_retry_preset, +) +from pybotx.integrations.fastapi import ( + FastAPIBotAppConfig, + create_fastapi_bot_app, + setup_fastapi_bot, +) from pybotx.client.smartapps_api.exceptions import SyncSmartAppEventHandlerNotFoundError from pybotx.client.smartapps_api.smartapp_manifest import ( SmartappManifest, @@ -192,10 +256,38 @@ "BotXAuthVersion", "BotIsNotChatMemberError", "BotMenu", + "BotCommandOverloadAction", + "BotCommandOverloadStrategy", + "BotCommandProcessingConfig", + "BotCommandRejectedError", + "BotIngressMetricsCollector", "BotSender", "BotShuttingDownError", "BotXMethodCallbackNotFoundError", "BotXMethodFailedCallbackReceivedError", + "BotXRequestMetadata", + "BotXRequestObserver", + "BotXRequestResult", + "BotXOperation", + "BotXRetryPolicy", + "BotXRetryRequestPolicy", + "BotXRetryStrategy", + "BotXRetryEvent", + "AnyOfBotXRetryRequestPolicy", + "BotObservabilityPreset", + "KnownSafeBotXRetryRequestPolicy", + "OpenTelemetryTracingCollector", + "OperationNameAllowlistBotXRetryRequestPolicy", + "PathNormalizer", + "PathAllowlistBotXRetryRequestPolicy", + "BotProductionPreset", + "PrometheusMetricsCollector", + "ReasonNormalizer", + "RetryAllBotXRequestsPolicy", + "BotRetryPreset", + "SafeBotXRetryRequestPolicy", + "SpanEnricher", + "TenacityRetryStrategy", "BotXMethodCallback", "BotsListItem", "BubbleMarkup", @@ -237,12 +329,19 @@ "File", "FileDeletedError", "FileMetadataNotFound", + "FastAPIBotAppConfig", "FinalRecipientsListEmptyError", "Forward", "HandlerCollector", + "HealthStatus", + "HealthcheckService", "Image", + "InMemoryIngressMetricsCollector", "IncomingMessage", "IncomingMessageHandlerFunc", + "IngressCommandMetadata", + "IngressCommandResult", + "IngressMetricsSnapshot", "InternalBotNotificationEvent", "InvalidBotAccountError", "InvalidBotXResponsePayloadError", @@ -264,10 +363,15 @@ "MessageNotFoundError", "MessageStatus", "Middleware", + "NoopIngressMetricsCollector", "OutgoingAttachment", "OutgoingMessage", "PermissionDeniedError", + "PrometheusIngressMetricsCollector", "RateLimitReachedError", + "ReadinessCheck", + "ReadinessCheckResult", + "RejectNewBotCommandOverloadStrategy", "Reply", "ReplyMessage", "RequestHeadersNotProvidedError", @@ -292,6 +396,7 @@ "ThreadAlreadyExistsError", "ThreadCreationError", "ThreadCreationProhibitedError", + "DropOldestBotCommandOverloadStrategy", "UnknownBotAccountError", "UnknownSystemEventError", "UnsupportedBotAPIVersionError", @@ -301,13 +406,23 @@ "UserFromSearch", "UserKinds", "UserNotFoundError", + "UserProfileUpdateUnavailableError", "UserSender", "Video", "Voice", "build_bot_disabled_response", + "build_healthcheck_router", "build_command_accepted_response", + "build_default_httpx_limits", + "build_default_httpx_timeout", + "build_production_bot_preset", + "build_production_observability_preset", + "build_production_retry_preset", "build_unverified_request_response", "lifespan_wrapper", + "setup_healthcheck", + "setup_fastapi_bot", + "create_fastapi_bot_app", ) logger.disable("pybotx") diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index aef8eb17..4dd5f56d 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1,6 +1,7 @@ from asyncio import Task from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager +from contextvars import ContextVar, Token from datetime import datetime from types import SimpleNamespace from typing import Any, TypeAlias @@ -15,11 +16,25 @@ from pybotx.async_buffer import AsyncBufferReadable, AsyncBufferWritable from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.auth import BotXAuthVersion -from pybotx.bot.callbacks.callback_manager import CallbackManager +from pybotx.bot.callbacks.callback_manager import ( + EXPIRED_SYNC_IDS_LIMIT, + EXPIRED_SYNC_IDS_TTL_SECONDS, + CallbackManager, +) +from pybotx.bot.command_processing import BotCommandProcessingConfig +from pybotx.bot.ingress_observability import ( + BotIngressMetricsCollector, + InMemoryIngressMetricsCollector, +) from pydantic import TypeAdapter from pybotx.bot.callbacks.callback_memory_repo import CallbackMemoryRepo from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto -from pybotx.bot.contextvars import bot_id_var, chat_id_var +from pybotx.bot.contextvars import ( + bot_id_var, + chat_id_var, + request_id_var, + trace_id_var, +) from pybotx.bot.exceptions import ( AnswerDestinationLookupError, RequestHeadersNotProvidedError, @@ -113,6 +128,15 @@ UploadFileMethod, ) from pybotx.client.get_token import get_token +from pybotx.client.http_config import ( + iter_retry_request_policy_warnings, + BotXRetryPolicy, + BotXRetryRequestPolicy, + BotXRetryStrategy, + build_default_httpx_limits, + build_default_httpx_timeout, +) +from pybotx.client.observability import BotXRequestObserver from pybotx.client.mertics_api.collect_bot_function import ( BotXAPICollectBotFunctionRequestPayload, CollectBotFunctionMethod, @@ -224,6 +248,11 @@ BotXAPIUsersAsCSVRequestPayload, UsersAsCSVMethod, ) +from pybotx.client.voex_api.get_call import BotXAPIGetCallRequestPayload, GetCallMethod +from pybotx.client.voex_api.get_conference import ( + BotXAPIGetConferenceRequestPayload, + GetConferenceMethod, +) from pybotx.constants import BOTX_DEFAULT_TIMEOUT, STICKER_PACKS_PER_PAGE from pybotx.converters import optional_sequence_to_list from pybotx.image_validators import ( @@ -236,12 +265,14 @@ from pybotx.models.attachments import IncomingFileAttachment, OutgoingAttachment from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.bot_catalog import BotsListItem +from pybotx.models.call import Call from pybotx.models.chats import ChatInfo, ChatLink, ChatListItem from pybotx.models.commands import ( BotAPISystemEvent, BotAPIIncomingMessage, BotCommand, ) +from pybotx.models.conference import Conference from pybotx.models.enums import BotAPICommandTypes, ChatLinkTypes, ChatTypes from pybotx.models.message.edit_message import EditMessage from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup @@ -278,8 +309,19 @@ def __init__( bot_accounts: Sequence[BotAccountWithSecret], middlewares: Sequence[Middleware] | None = None, httpx_client: httpx.AsyncClient | None = None, + httpx_timeout: httpx.Timeout | None = None, + httpx_limits: httpx.Limits | None = None, + retry_policy: BotXRetryPolicy | None = None, + retry_request_policy: BotXRetryRequestPolicy | None = None, + retry_strategy: BotXRetryStrategy | None = None, + metrics_collector: BotXRequestObserver | None = None, + tracing_collector: BotXRequestObserver | None = None, + command_processing_config: BotCommandProcessingConfig | None = None, + ingress_metrics_collector: BotIngressMetricsCollector | None = None, exception_handlers: ExceptionHandlersDict | None = None, default_callback_timeout: float = BOTX_DEFAULT_TIMEOUT, + expired_sync_ids_ttl_seconds: float = EXPIRED_SYNC_IDS_TTL_SECONDS, + expired_sync_ids_limit: int = EXPIRED_SYNC_IDS_LIMIT, callback_repo: CallbackRepoProto | None = None, auth_version: BotXAuthVersion = BotXAuthVersion.V2, ) -> None: @@ -289,9 +331,14 @@ def __init__( logger.warning("Bot has no bot accounts") middlewares = optional_sequence_to_list(middlewares) + self._ingress_metrics_collector = ( + ingress_metrics_collector or InMemoryIngressMetricsCollector() + ) self._handler_collector = self._build_main_collector( collectors, middlewares, + command_processing_config, + self._ingress_metrics_collector, exception_handlers, ) @@ -299,13 +346,42 @@ def __init__( self._bot_accounts_storage = BotAccountsStorage( list(bot_accounts), auth_version=auth_version, - ) - self._httpx_client = httpx_client or httpx.AsyncClient() + retry_policy=retry_policy, + retry_request_policy=retry_request_policy, + retry_strategy=retry_strategy, + metrics_collector=metrics_collector, + tracing_collector=tracing_collector, + ) + if retry_policy is not None: + resolved_retry_request_policy = ( + self._bot_accounts_storage.get_retry_request_policy() + ) + if resolved_retry_request_policy is not None: + for warning_message in iter_retry_request_policy_warnings( + resolved_retry_request_policy, + ): + logger.warning(warning_message) + if httpx_client is not None: + if httpx_timeout is not None or httpx_limits is not None: + raise ValueError( + "`httpx_timeout` and `httpx_limits` can't be passed with" + " custom `httpx_client`", + ) + self._httpx_client = httpx_client + else: + self._httpx_client = httpx.AsyncClient( + timeout=httpx_timeout or build_default_httpx_timeout(), + limits=httpx_limits or build_default_httpx_limits(), + ) if not callback_repo: callback_repo = CallbackMemoryRepo() - self._callbacks_manager = CallbackManager(callback_repo) + self._callbacks_manager = CallbackManager( + callback_repo, + expired_sync_ids_ttl_seconds=expired_sync_ids_ttl_seconds, + expired_sync_ids_limit=expired_sync_ids_limit, + ) self.state: SimpleNamespace = SimpleNamespace() @@ -316,26 +392,53 @@ def async_execute_raw_bot_command( request_headers: Mapping[str, str] | None = None, logging_command: bool = True, trusted_issuers: set[str] | None = None, - ) -> None: - if logging_command: - log_incoming_request(raw_bot_command, message="Got command: ") - - if verify_request: - self._verify_request(request_headers, trusted_issuers=trusted_issuers) - + ) -> "Task[None]": + context_tokens = self._set_ingress_contextvars( + payload=raw_bot_command, + request_headers=request_headers, + ) try: - command_type = raw_bot_command.get("command", {}).get("command_type") - if command_type == BotAPICommandTypes.USER: - bot_api_command = BotAPIIncomingMessage.model_validate(raw_bot_command) - else: - bot_api_command = TypeAdapter(BotAPISystemEvent).validate_python( - raw_bot_command - ) - except ValidationError as validation_exc: - raise ValueError("Bot command validation error") from validation_exc + if logging_command: + log_incoming_request(raw_bot_command, message="Got command: ") - bot_command = bot_api_command.to_domain(raw_bot_command) - self.async_execute_bot_command(bot_command) + if verify_request: + self._verify_request(request_headers, trusted_issuers=trusted_issuers) + + try: + command_type = raw_bot_command.get("command", {}).get("command_type") + if command_type == BotAPICommandTypes.USER: + bot_api_command = BotAPIIncomingMessage.model_validate(raw_bot_command) + else: + bot_api_command = TypeAdapter(BotAPISystemEvent).validate_python( + raw_bot_command + ) + except ValidationError as validation_exc: + raise ValueError("Bot command validation error") from validation_exc + + bot_command = bot_api_command.to_domain(raw_bot_command) + return self.async_execute_bot_command(bot_command) + finally: + self._reset_contextvars(context_tokens) + + async def dispatch_raw_command( + self, + raw_bot_command: dict[str, Any], + verify_request: bool = True, + request_headers: Mapping[str, str] | None = None, + logging_command: bool = True, + trusted_issuers: set[str] | None = None, + *, + wait: bool = True, + ) -> None: + task = self.async_execute_raw_bot_command( + raw_bot_command, + verify_request=verify_request, + request_headers=request_headers, + logging_command=logging_command, + trusted_issuers=trusted_issuers, + ) + if wait: + await task def async_execute_bot_command( self, @@ -354,26 +457,33 @@ async def sync_execute_raw_smartapp_event( logging_command: bool = True, trusted_issuers: set[str] | None = None, ) -> BotAPISyncSmartAppEventResponse: - if logging_command: - log_incoming_request( - raw_smartapp_event, - message="Got sync smartapp event: ", - ) + context_tokens = self._set_ingress_contextvars( + payload=raw_smartapp_event, + request_headers=request_headers, + ) + try: + if logging_command: + log_incoming_request( + raw_smartapp_event, + message="Got sync smartapp event: ", + ) - if verify_request: - self._verify_request(request_headers, trusted_issuers=trusted_issuers) + if verify_request: + self._verify_request(request_headers, trusted_issuers=trusted_issuers) - try: - bot_api_smartapp_event = BotAPISyncSmartAppEvent.model_validate( - raw_smartapp_event - ) - except ValidationError as validation_exc: - raise ValueError( - "Sync smartapp event validation error", - ) from validation_exc + try: + bot_api_smartapp_event = BotAPISyncSmartAppEvent.model_validate( + raw_smartapp_event + ) + except ValidationError as validation_exc: + raise ValueError( + "Sync smartapp event validation error", + ) from validation_exc - smartapp_event = bot_api_smartapp_event.to_domain(raw_smartapp_event) - return await self.sync_execute_smartapp_event(smartapp_event) + smartapp_event = bot_api_smartapp_event.to_domain(raw_smartapp_event) + return await self.sync_execute_smartapp_event(smartapp_event) + finally: + self._reset_contextvars(context_tokens) async def sync_execute_smartapp_event( self, @@ -392,25 +502,32 @@ async def raw_get_status( request_headers: Mapping[str, str] | None = None, trusted_issuers: set[str] | None = None, ) -> dict[str, Any]: - logger.opt(lazy=True).debug( - "Got status: {status}", - status=lambda: pformat_jsonable_obj(query_params), + context_tokens = self._set_ingress_contextvars( + payload=query_params, + request_headers=request_headers, ) - - if verify_request: - self._verify_request(request_headers, trusted_issuers=trusted_issuers) - try: - bot_api_status_recipient = BotAPIStatusRecipient.model_validate( - query_params + logger.opt(lazy=True).debug( + "Got status: {status}", + status=lambda: pformat_jsonable_obj(query_params), ) - except ValidationError as exc: - raise ValueError("Status request validation error") from exc - status_recipient = bot_api_status_recipient.to_domain() + if verify_request: + self._verify_request(request_headers, trusted_issuers=trusted_issuers) + + try: + bot_api_status_recipient = BotAPIStatusRecipient.model_validate( + query_params + ) + except ValidationError as exc: + raise ValueError("Status request validation error") from exc - bot_menu = await self.get_status(status_recipient) - return build_bot_status_response(bot_menu) + status_recipient = bot_api_status_recipient.to_domain() + + bot_menu = await self.get_status(status_recipient) + return build_bot_status_response(bot_menu) + finally: + self._reset_contextvars(context_tokens) async def get_status(self, status_recipient: StatusRecipient) -> BotMenu: # raise UnknownBotAccountError if no bot account with this bot_id. @@ -425,17 +542,40 @@ async def set_raw_botx_method_result( request_headers: Mapping[str, str] | None = None, trusted_issuers: set[str] | None = None, ) -> None: - logger.debug("Got callback: {callback}", callback=raw_botx_method_result) + context_tokens = self._set_ingress_contextvars( + payload=raw_botx_method_result, + request_headers=request_headers, + ) + try: + logger.debug("Got callback: {callback}", callback=raw_botx_method_result) - if verify_request: - self._verify_request(request_headers, trusted_issuers=trusted_issuers) + if verify_request: + self._verify_request(request_headers, trusted_issuers=trusted_issuers) + + callback: BotXMethodCallback = TypeAdapter( + BotXMethodCallback + ).validate_python( + raw_botx_method_result, + ) - callback: BotXMethodCallback = TypeAdapter(BotXMethodCallback).validate_python( + await self._callbacks_manager.set_botx_method_callback_result(callback) + finally: + self._reset_contextvars(context_tokens) + + async def deliver_raw_callback( + self, + raw_botx_method_result: dict[str, Any], + verify_request: bool = True, + request_headers: Mapping[str, str] | None = None, + trusted_issuers: set[str] | None = None, + ) -> None: + await self.set_raw_botx_method_result( raw_botx_method_result, + verify_request=verify_request, + request_headers=request_headers, + trusted_issuers=trusted_issuers, ) - await self._callbacks_manager.set_botx_method_callback_result(callback) - async def wait_botx_method_callback( self, sync_id: UUID, @@ -1395,6 +1535,56 @@ async def pin_message( await method.execute(payload) + async def get_call( + self, + *, + bot_id: UUID, + call_id: UUID, + ) -> Call: + """Get call. + + :param bot_id: Bot which should perform the request. + :param call_id: Call id. + + :return: Call. + """ + method = GetCallMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetCallRequestPayload.from_domain( + call_id=call_id, + ) + botx_call = await method.execute(payload) + + return botx_call.to_domain() + + async def get_conference( + self, + *, + bot_id: UUID, + call_id: UUID, + ) -> Conference: + """Get Conference. + + :param bot_id: Bot which should perform the request. + :param call_id: Call id. + + :return: Conference. + """ + method = GetConferenceMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetConferenceRequestPayload.from_domain( + call_id=call_id, + ) + botx_conference = await method.execute(payload) + + return botx_conference.to_domain() + async def unpin_message( self, *, @@ -1421,6 +1611,8 @@ async def search_user_by_emails( *, bot_id: UUID, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> list[UserFromSearch]: """Search user by emails for search. @@ -1435,7 +1627,11 @@ async def search_user_by_emails( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByEmailsRequestPayload.from_domain(emails=emails) + payload = BotXAPISearchUserByEmailsRequestPayload.from_domain( + emails=emails, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_users_from_search = await method.execute(payload) @@ -1502,11 +1698,15 @@ async def search_user_by_huid( *, bot_id: UUID, huid: UUID, + trusts_search: bool = False, + partial_response: bool = False, ) -> UserFromSearch: """Search user by huid for search. :param bot_id: Bot which should perform the request. :param huid: User huid. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: User information. """ @@ -1516,7 +1716,11 @@ async def search_user_by_huid( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByHUIDRequestPayload.from_domain(huid=huid) + payload = BotXAPISearchUserByHUIDRequestPayload.from_domain( + huid=huid, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_user_from_search = await method.execute(payload) @@ -1528,12 +1732,16 @@ async def search_user_by_ad( bot_id: UUID, ad_login: str, ad_domain: str, + trusts_search: bool = False, + partial_response: bool = False, ) -> UserFromSearch: """Search user by AD login and AD domain for search. :param bot_id: Bot which should perform the request. :param ad_login: User AD login. :param ad_domain: User AD domain. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: User information. """ @@ -1546,6 +1754,8 @@ async def search_user_by_ad( payload = BotXAPISearchUserByLoginRequestPayload.from_domain( ad_login=ad_login, ad_domain=ad_domain, + trusts_search=trusts_search, + partial_response=partial_response, ) botx_api_user_from_search = await method.execute(payload) @@ -1557,11 +1767,15 @@ async def search_user_by_other_id( *, bot_id: UUID, other_id: str, + trusts_search: bool = False, + partial_response: bool = False, ) -> UserFromSearch: """Search user by other identificator for search. :param bot_id: Bot which should perform the request. :param other_id: User other identificator. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: User information. """ @@ -1573,6 +1787,8 @@ async def search_user_by_other_id( ) payload = BotXAPISearchUserByOtherIdRequestPayload.from_domain( other_id=other_id, + trusts_search=trusts_search, + partial_response=partial_response, ) botx_api_user_from_search = await method.execute(payload) @@ -1794,9 +2010,6 @@ async def send_smartapp_manifest( :param android: Smartapp layout for android clients. :param web_layout: Smartapp layout for web clients. :param unread_counter: Entities that can be subscribed to in the unread counter. - :param store_on_close: Keep smartapp in memory on close. - :param preload_in_background: Force load smartapp even not pinned. - :param link_regex: URLs matching this Regex will be opened by the current SmartApp. :return: Smartapp manifest with the set parameters received from BotX. """ @@ -2302,6 +2515,125 @@ async def collect_metric( ) await method.execute(payload) + def _set_ingress_contextvars( + self, + *, + payload: Mapping[str, Any] | None, + request_headers: Mapping[str, str] | None, + ) -> list[tuple[ContextVar[Any], Token[Any]]]: + context_tokens: list[tuple[ContextVar[Any], Token[Any]]] = [] + request_id = self._extract_request_id(payload, request_headers) + trace_id = self._extract_trace_id(request_headers, request_id) + if request_id is not None: + context_tokens.append((request_id_var, request_id_var.set(request_id))) + if trace_id is not None: + context_tokens.append((trace_id_var, trace_id_var.set(trace_id))) + + bot_id = self._extract_bot_id(payload) + if bot_id is not None: + context_tokens.append((bot_id_var, bot_id_var.set(bot_id))) + + chat_id = self._extract_chat_id(payload) + if chat_id is not None: + context_tokens.append((chat_id_var, chat_id_var.set(chat_id))) + + return context_tokens + + def _reset_contextvars( + self, + context_tokens: list[tuple[ContextVar[Any], Token[Any]]], + ) -> None: + for context_var, token in reversed(context_tokens): + context_var.reset(token) + + def _extract_request_id( + self, + payload: Mapping[str, Any] | None, + request_headers: Mapping[str, str] | None, + ) -> str | None: + for header_name in ("x-request-id", "x-correlation-id", "request-id"): + if request_id := self._get_header_value(request_headers, header_name): + return request_id + + if payload is None: + return None + return self._normalize_correlation_value(payload.get("sync_id")) + + def _extract_trace_id( + self, + request_headers: Mapping[str, str] | None, + fallback_request_id: str | None, + ) -> str | None: + if trace_id := self._get_header_value(request_headers, "x-trace-id"): + return trace_id + + traceparent = self._get_header_value(request_headers, "traceparent") + if traceparent: + parts = traceparent.split("-") + if len(parts) >= 4: + trace_id = parts[1].lower() + if len(trace_id) == 32 and all(char in "0123456789abcdef" for char in trace_id): + return trace_id + + b3 = self._get_header_value(request_headers, "b3") + if b3: + trace_id = b3.split("-", maxsplit=1)[0].lower() + if all(char in "0123456789abcdef" for char in trace_id): + return trace_id + + return fallback_request_id + + def _extract_bot_id(self, payload: Mapping[str, Any] | None) -> UUID | None: + if payload is None: + return None + return self._extract_uuid(payload.get("bot_id")) + + def _extract_chat_id(self, payload: Mapping[str, Any] | None) -> UUID | None: + if payload is None: + return None + from_payload = payload.get("from") + if not isinstance(from_payload, Mapping): + return self._extract_uuid(payload.get("group_chat_id")) + + chat_id = self._extract_uuid(from_payload.get("group_chat_id")) + if chat_id is not None: + return chat_id + + return self._extract_uuid(payload.get("group_chat_id")) + + def _get_header_value( + self, + request_headers: Mapping[str, str] | None, + header_name: str, + ) -> str | None: + if request_headers is None: + return None + for key, value in request_headers.items(): + if key.lower() != header_name: + continue + return self._normalize_correlation_value(value) + return None + + def _normalize_correlation_value(self, value: Any) -> str | None: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + return None + return normalized[:128] + + def _extract_uuid(self, value: Any) -> UUID | None: + if value is None: + return None + if isinstance(value, UUID): + return value + if not isinstance(value, str): + return None + try: + return UUID(value) + except ValueError: + return None + def _verify_request( self, headers: Mapping[str, str] | None, @@ -2443,9 +2775,15 @@ def _verify_request_v1( def _build_main_collector( collectors: Sequence[HandlerCollector], middlewares: list[Middleware], + command_processing_config: BotCommandProcessingConfig | None = None, + ingress_metrics_collector: BotIngressMetricsCollector | None = None, exception_handlers: ExceptionHandlersDict | None = None, ) -> HandlerCollector: - main_collector = HandlerCollector(middlewares=middlewares) + main_collector = HandlerCollector( + middlewares=middlewares, + command_processing_config=command_processing_config, + ingress_metrics_collector=ingress_metrics_collector, + ) main_collector.insert_exception_middleware(exception_handlers) main_collector.include(*collectors) diff --git a/pybotx/bot/bot_accounts_storage.py b/pybotx/bot/bot_accounts_storage.py index d4d1a556..2f960469 100644 --- a/pybotx/bot/bot_accounts_storage.py +++ b/pybotx/bot/bot_accounts_storage.py @@ -6,6 +6,13 @@ from pybotx.auth import BotXAuthVersion, build_botx_jwt_v2 from pybotx.bot.exceptions import UnknownBotAccountError +from pybotx.client.http_config import ( + BotXRetryPolicy, + BotXRetryRequestPolicy, + BotXRetryStrategy, + SafeBotXRetryRequestPolicy, +) +from pybotx.client.observability import BotXRequestObserver from pybotx.models.bot_account import BotAccountWithSecret @@ -14,10 +21,26 @@ def __init__( self, bot_accounts: list[BotAccountWithSecret], auth_version: BotXAuthVersion = BotXAuthVersion.V2, + retry_policy: BotXRetryPolicy | None = None, + retry_request_policy: BotXRetryRequestPolicy | None = None, + retry_strategy: BotXRetryStrategy | None = None, + metrics_collector: BotXRequestObserver | None = None, + tracing_collector: BotXRequestObserver | None = None, ) -> None: self._bot_accounts = bot_accounts self._auth_tokens: dict[UUID, str] = {} self._auth_version = auth_version + self._retry_policy = retry_policy + self._retry_request_policy = ( + retry_request_policy + if retry_policy is not None + else None + ) + if retry_policy is not None and self._retry_request_policy is None: + self._retry_request_policy = SafeBotXRetryRequestPolicy() + self._retry_strategy = retry_strategy + self._metrics_collector = metrics_collector + self._tracing_collector = tracing_collector def get_bot_account(self, bot_id: UUID) -> BotAccountWithSecret: for bot_account in self._bot_accounts: @@ -32,6 +55,21 @@ def iter_bot_accounts(self) -> Iterator[BotAccountWithSecret]: def get_auth_version(self) -> BotXAuthVersion: return self._auth_version + def get_retry_policy(self) -> BotXRetryPolicy | None: + return self._retry_policy + + def get_retry_request_policy(self) -> BotXRetryRequestPolicy | None: + return self._retry_request_policy + + def get_retry_strategy(self) -> BotXRetryStrategy | None: + return self._retry_strategy + + def iter_request_observers(self) -> Iterator[BotXRequestObserver]: + if self._metrics_collector is not None: + yield self._metrics_collector + if self._tracing_collector is not None: + yield self._tracing_collector + def get_cts_url(self, bot_id: UUID) -> str: bot_account = self.get_bot_account(bot_id) return str(bot_account.cts_url) diff --git a/pybotx/bot/callbacks/callback_manager.py b/pybotx/bot/callbacks/callback_manager.py index a396a1c7..8543c287 100644 --- a/pybotx/bot/callbacks/callback_manager.py +++ b/pybotx/bot/callbacks/callback_manager.py @@ -1,4 +1,6 @@ import asyncio +from collections import OrderedDict +from time import monotonic from typing import Literal, NamedTuple, overload from uuid import UUID @@ -10,6 +12,8 @@ ORPHAN_CALLBACK_TTL_SECONDS = 5.0 ORPHAN_PENDING_CALLBACKS_LIMIT = 1000 +EXPIRED_SYNC_IDS_TTL_SECONDS = 15 * 60.0 +EXPIRED_SYNC_IDS_LIMIT = 100_000 class CallbackAlarm(NamedTuple): @@ -47,13 +51,29 @@ async def _orphan_callback_alarm( class CallbackManager: - def __init__(self, callback_repo: CallbackRepoProto) -> None: + def __init__( + self, + callback_repo: CallbackRepoProto, + *, + expired_sync_ids_ttl_seconds: float = EXPIRED_SYNC_IDS_TTL_SECONDS, + expired_sync_ids_limit: int = EXPIRED_SYNC_IDS_LIMIT, + ) -> None: + if expired_sync_ids_ttl_seconds <= 0: + raise ValueError( + "Expired sync ids ttl should be greater than 0", + ) + if expired_sync_ids_limit < 1: + raise ValueError( + "Expired sync ids limit should be greater than 0", + ) self._callback_repo = callback_repo self._callback_alarms: dict[UUID, CallbackAlarm] = {} self._orphan_callback_alarms: dict[UUID, CallbackAlarm] = {} self._expected_sync_ids: set[UUID] = set() self._pending_callbacks: dict[UUID, BotXMethodCallback] = {} - self._expired_sync_ids: set[UUID] = set() + self._expired_sync_ids: OrderedDict[UUID, float] = OrderedDict() + self._expired_sync_ids_ttl_seconds = expired_sync_ids_ttl_seconds + self._expired_sync_ids_limit = expired_sync_ids_limit def register_expected_callback(self, sync_id: UUID) -> None: self._expected_sync_ids.add(sync_id) @@ -72,7 +92,7 @@ async def set_botx_method_callback_result( callback: BotXMethodCallback, ) -> None: sync_id = callback.sync_id - if sync_id in self._expired_sync_ids: + if self._is_expired_sync_id(sync_id): raise BotXMethodCallbackNotFoundError(sync_id) from None try: await self._callback_repo.set_botx_method_callback_result(callback) @@ -181,7 +201,7 @@ def mark_callback_expired(self, sync_id: UUID) -> None: self._mark_callback_expired(sync_id) def _mark_callback_expired(self, sync_id: UUID) -> None: - self._expired_sync_ids.add(sync_id) + self._remember_expired_sync_id(sync_id) self._pending_callbacks.pop(sync_id, None) self._expected_sync_ids.discard(sync_id) self.cancel_orphan_callback_alarm(sync_id) @@ -189,3 +209,31 @@ def _mark_callback_expired(self, sync_id: UUID) -> None: def drop_orphan_callback(self, sync_id: UUID) -> None: self._pending_callbacks.pop(sync_id, None) self.cancel_orphan_callback_alarm(sync_id) + + def _remember_expired_sync_id(self, sync_id: UUID) -> None: + expires_at = monotonic() + self._expired_sync_ids_ttl_seconds + self._expired_sync_ids[sync_id] = expires_at + self._expired_sync_ids.move_to_end(sync_id) + self._cleanup_expired_sync_ids() + + def _is_expired_sync_id(self, sync_id: UUID) -> bool: + self._cleanup_expired_sync_ids() + expires_at = self._expired_sync_ids.get(sync_id) + if expires_at is None: + return False + if expires_at <= monotonic(): + self._expired_sync_ids.pop(sync_id, None) + return False + return True + + def _cleanup_expired_sync_ids(self) -> None: + now = monotonic() + while self._expired_sync_ids: + _, oldest_expire_at = next(iter(self._expired_sync_ids.items())) + if ( + oldest_expire_at <= now + or len(self._expired_sync_ids) > self._expired_sync_ids_limit + ): + self._expired_sync_ids.popitem(last=False) + continue + break diff --git a/pybotx/bot/command_processing.py b/pybotx/bot/command_processing.py new file mode 100644 index 00000000..a4bb2b17 --- /dev/null +++ b/pybotx/bot/command_processing.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + +from pybotx.constants import ( + BOTX_COMMAND_PROCESSING_MAX_CONCURRENCY, + BOTX_COMMAND_PROCESSING_MAX_QUEUE_SIZE, +) + + +class BotCommandOverloadAction(str, Enum): + REJECT_NEW = "reject_new" + DROP_OLDEST = "drop_oldest" + + +class BotCommandOverloadStrategy(Protocol): + def on_queue_overflow( # pragma: no cover + self, + *, + queue_size: int, + queue_max_size: int, + ) -> BotCommandOverloadAction: ... + + +@dataclass(frozen=True, slots=True) +class RejectNewBotCommandOverloadStrategy(BotCommandOverloadStrategy): + def on_queue_overflow( + self, + *, + queue_size: int, + queue_max_size: int, + ) -> BotCommandOverloadAction: + return BotCommandOverloadAction.REJECT_NEW + + +@dataclass(frozen=True, slots=True) +class DropOldestBotCommandOverloadStrategy(BotCommandOverloadStrategy): + def on_queue_overflow( + self, + *, + queue_size: int, + queue_max_size: int, + ) -> BotCommandOverloadAction: + return BotCommandOverloadAction.DROP_OLDEST + + +@dataclass(frozen=True, slots=True) +class BotCommandProcessingConfig: + max_concurrency: int = BOTX_COMMAND_PROCESSING_MAX_CONCURRENCY + max_queue_size: int = BOTX_COMMAND_PROCESSING_MAX_QUEUE_SIZE + overload_strategy: BotCommandOverloadStrategy | None = None + + def __post_init__(self) -> None: + if self.max_concurrency < 1: + raise ValueError( + "Command processing max concurrency should be greater than 0", + ) + if self.max_queue_size < 1: + raise ValueError("Command processing max queue size should be greater than 0") + + def get_overload_strategy(self) -> BotCommandOverloadStrategy: + return self.overload_strategy or RejectNewBotCommandOverloadStrategy() diff --git a/pybotx/bot/contextvars.py b/pybotx/bot/contextvars.py index 3a77ee6a..8b7595e9 100644 --- a/pybotx/bot/contextvars.py +++ b/pybotx/bot/contextvars.py @@ -8,3 +8,5 @@ bot_var: ContextVar["Bot"] = ContextVar("bot_var") bot_id_var: ContextVar[UUID] = ContextVar("bot_id") chat_id_var: ContextVar[UUID] = ContextVar("chat_id") +request_id_var: ContextVar[str] = ContextVar("request_id") +trace_id_var: ContextVar[str] = ContextVar("trace_id") diff --git a/pybotx/bot/exceptions.py b/pybotx/bot/exceptions.py index 0257106d..ace2f5f7 100644 --- a/pybotx/bot/exceptions.py +++ b/pybotx/bot/exceptions.py @@ -40,3 +40,9 @@ def __init__(self, *args: Any) -> None: class UnverifiedRequestError(Exception): """The authorization header is missing or the token is invalid.""" + + +class BotCommandRejectedError(Exception): + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(f"Bot command rejected: {reason}") diff --git a/pybotx/bot/handler_collector.py b/pybotx/bot/handler_collector.py index 3a5f979a..92bb02a9 100644 --- a/pybotx/bot/handler_collector.py +++ b/pybotx/bot/handler_collector.py @@ -1,5 +1,7 @@ import asyncio import re +from contextvars import Context, ContextVar, Token +from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, @@ -8,7 +10,18 @@ from collections.abc import Callable, Sequence from weakref import WeakSet -from pybotx.bot.contextvars import bot_id_var, bot_var, chat_id_var +from pybotx.bot.command_processing import ( + BotCommandOverloadAction, + BotCommandProcessingConfig, +) +from pybotx.bot.contextvars import ( + bot_id_var, + bot_var, + chat_id_var, + request_id_var, + trace_id_var, +) +from pybotx.bot.exceptions import BotCommandRejectedError from pybotx.bot.handler import ( CommandHandler, DefaultMessageHandler, @@ -21,6 +34,12 @@ VisibleCommandHandler, VisibleFunc, ) +from pybotx.bot.ingress_observability import ( + BotIngressMetricsCollector, + IngressCommandMetadata, + IngressCommandResult, + NoopIngressMetricsCollector, +) from pybotx.bot.middlewares.exception_middleware import ( ExceptionHandlersDict, ExceptionMiddleware, @@ -59,10 +78,29 @@ ] +@dataclass(slots=True) +class _QueuedBotCommand: + bot: "Bot" + bot_command: BotCommand + completion: asyncio.Future[None] + request_id: str | None + trace_id: str | None + ingress_metadata: IngressCommandMetadata + + async def execute(self, collector: "HandlerCollector") -> None: + await collector.handle_bot_command(self.bot_command, self.bot) + + class HandlerCollector: VALID_COMMAND_NAME_RE = re.compile(r"^\/[^\s\/]+$", flags=re.UNICODE) + _STOP_QUEUE_ITEM: "_QueuedBotCommand | None" = None - def __init__(self, middlewares: Sequence[Middleware] | None = None) -> None: + def __init__( + self, + middlewares: Sequence[Middleware] | None = None, + command_processing_config: BotCommandProcessingConfig | None = None, + ingress_metrics_collector: BotIngressMetricsCollector | None = None, + ) -> None: self._user_commands_handlers: dict[str, CommandHandler] = {} self._default_message_handler: DefaultMessageHandler | None = None self._system_events_handlers: dict[ @@ -74,7 +112,25 @@ def __init__(self, middlewares: Sequence[Middleware] | None = None) -> None: SyncSmartAppEventHandlerFunc, ] = {} self._middlewares = optional_sequence_to_list(middlewares) + self._command_processing_config = ( + command_processing_config or BotCommandProcessingConfig() + ) + self._overload_strategy = ( + self._command_processing_config.get_overload_strategy() + ) + self._command_queue: asyncio.Queue[_QueuedBotCommand | None] = asyncio.Queue( + maxsize=self._command_processing_config.max_queue_size, + ) + self._processing_semaphore = asyncio.Semaphore( + self._command_processing_config.max_concurrency, + ) + self._workers: set[asyncio.Task[None]] = set() + self._is_shutting_down = False self._tasks: WeakSet[asyncio.Task[None]] = WeakSet() + self._ingress_metrics_collector = ( + ingress_metrics_collector or NoopIngressMetricsCollector() + ) + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) def include(self, *others: "HandlerCollector") -> None: """Include other `HandlerCollector`.""" @@ -86,9 +142,24 @@ def async_handle_bot_command( bot: "Bot", bot_command: BotCommand, ) -> "asyncio.Task[None]": - task = asyncio.create_task( - self.handle_bot_command(bot_command, bot), + ingress_metadata = self._build_ingress_command_metadata(bot_command) + request_id = self._get_optional_context_value(request_id_var) + if request_id is None: + request_id = self._extract_request_id_from_command(bot_command) + trace_id = self._get_optional_context_value(trace_id_var) or request_id + completion = asyncio.get_running_loop().create_future() + queued_command = _QueuedBotCommand( + bot=bot, + bot_command=bot_command, + completion=completion, + request_id=request_id, + trace_id=trace_id, + ingress_metadata=ingress_metadata, ) + self._enqueue_or_reject(queued_command) + + task = asyncio.create_task(self._wait_for_completion(completion)) + task.add_done_callback(self._log_unhandled_task_exception) self._tasks.add(task) return task @@ -101,15 +172,21 @@ async def handle_incoming_message_by_command( ) -> None: message_handler = self._get_command_handler(command) if message_handler: - self._fill_contextvars(message, bot) - await message_handler(message, bot) + context_tokens = self._set_contextvars(message, bot) + try: + await message_handler(message, bot) + finally: + self._reset_contextvars(context_tokens) async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None: if isinstance(bot_command, IncomingMessage): message_handler = self._get_incoming_message_handler(bot_command) if message_handler: - self._fill_contextvars(bot_command, bot) - await message_handler(bot_command, bot) + context_tokens = self._set_contextvars(bot_command, bot) + try: + await message_handler(bot_command, bot) + finally: + self._reset_contextvars(context_tokens) elif isinstance( bot_command, @@ -117,8 +194,11 @@ async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None: ): event_handler = self._get_system_event_handler_or_none(bot_command) if event_handler: - self._fill_contextvars(bot_command, bot) - await event_handler(bot_command, bot) + context_tokens = self._set_contextvars(bot_command, bot) + try: + await event_handler(bot_command, bot) + finally: + self._reset_contextvars(context_tokens) else: raise NotImplementedError(f"Unsupported event type: `{bot_command}`") @@ -140,8 +220,28 @@ async def handle_sync_smartapp_event( "Handler for sync smartapp event not found", ) - self._fill_contextvars(smartapp_event, bot) - return await event_handler(smartapp_event, bot) + context_tokens = self._set_contextvars(smartapp_event, bot) + started_at = asyncio.get_running_loop().time() + handler_error: Exception | None = None + try: + return await event_handler(smartapp_event, bot) + except Exception as exc: + handler_error = exc + raise + finally: + duration_ms = int((asyncio.get_running_loop().time() - started_at) * 1000) + self._ingress_metrics_collector.on_command_finished( + IngressCommandMetadata( + command_kind="sync_smartapp_event", + command_name=smartapp_event.__class__.__name__, + ), + IngressCommandResult( + duration_ms=max(duration_ms, 0), + error=handler_error, + ), + queue_depth=self._command_queue.qsize(), + ) + self._reset_contextvars(context_tokens) async def get_bot_menu( self, @@ -363,12 +463,17 @@ def insert_exception_middleware( self._middlewares.insert(0, exception_middleware.dispatch) async def wait_active_tasks(self) -> None: + self._is_shutting_down = True + await self._command_queue.join() + if self._tasks: await asyncio.wait( self._tasks, return_when=asyncio.ALL_COMPLETED, ) + await self._stop_workers() + def _include_collector(self, other: "HandlerCollector") -> None: # - Message handlers - command_duplicates = set(self._user_commands_handlers) & set( @@ -522,13 +627,28 @@ def _sync_smartapp_event( return handler_func - def _fill_contextvars(self, bot_command: BotCommand, bot: "Bot") -> None: - bot_var.set(bot) - bot_id_var.set(bot_command.bot.id) + def _set_contextvars( + self, + bot_command: BotCommand, + bot: "Bot", + ) -> list[tuple[ContextVar[Any], Token[Any]]]: + context_tokens: list[tuple[ContextVar[Any], Token[Any]]] = [ + (bot_var, bot_var.set(bot)), + (bot_id_var, bot_id_var.set(bot_command.bot.id)), + ] chat = getattr(bot_command, "chat", None) - if chat: - chat_id_var.set(chat.id) + if chat is not None: + context_tokens.append((chat_id_var, chat_id_var.set(chat.id))) + + return context_tokens + + def _reset_contextvars( + self, + context_tokens: list[tuple[ContextVar[Any], Token[Any]]], + ) -> None: + for context_var, token in reversed(context_tokens): + context_var.reset(token) def _log_system_event_handler_call( self, @@ -548,3 +668,196 @@ def _log_default_handler_call(self, command_name: str | None) -> None: ) else: logger.info("No command found, using default handler") + + async def _wait_for_completion( + self, + completion: asyncio.Future[None], + ) -> None: + await completion + + def _enqueue_or_reject(self, queued_command: _QueuedBotCommand) -> None: + if self._is_shutting_down: + self._reject_queued_command( + queued_command, + reason="bot_shutting_down", + ) + return + + self._ensure_workers_started() + + try: + self._command_queue.put_nowait(queued_command) + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) + return + except asyncio.QueueFull: + pass + + action = self._overload_strategy.on_queue_overflow( + queue_size=self._command_queue.qsize(), + queue_max_size=self._command_queue.maxsize, + ) + if action is BotCommandOverloadAction.DROP_OLDEST: + dropped_command = self._drop_oldest_queued_command() + if dropped_command is not None: + self._reject_queued_command( + dropped_command, + reason="queue_overflow_drop_oldest", + ) + self._command_queue.task_done() + self._command_queue.put_nowait(queued_command) + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) + logger.warning( + "Bot command queue overflow: dropped oldest queued command and " + "accepted newest command", + ) + return + + self._reject_queued_command( + queued_command, + reason="queue_overflow_reject_new", + ) + logger.warning( + "Bot command queue overflow: rejected incoming command", + ) + + def _drop_oldest_queued_command(self) -> _QueuedBotCommand | None: + while True: + try: + queued_item = self._command_queue.get_nowait() + except asyncio.QueueEmpty: + return None + + if queued_item is self._STOP_QUEUE_ITEM: + self._command_queue.task_done() + continue + + return queued_item + + def _reject_queued_command( + self, + queued_command: _QueuedBotCommand, + *, + reason: str, + ) -> None: + if queued_command.completion.done(): + return + queued_command.completion.set_exception(BotCommandRejectedError(reason)) + self._ingress_metrics_collector.on_command_rejected( + queued_command.ingress_metadata, + reason=reason, + queue_depth=self._command_queue.qsize(), + ) + + def _ensure_workers_started(self) -> None: + if self._workers: + return + + for index in range(self._command_processing_config.max_concurrency): + worker_task = Context().run(asyncio.create_task, self._command_worker()) + worker_task.set_name(f"pybotx-command-worker-{index + 1}") + self._workers.add(worker_task) + + async def _stop_workers(self) -> None: + if not self._workers: + return + + workers_count = len(self._workers) + for _ in range(workers_count): + await self._command_queue.put(self._STOP_QUEUE_ITEM) + + workers = tuple(self._workers) + await asyncio.wait(workers, return_when=asyncio.ALL_COMPLETED) + self._workers.clear() + + async def _command_worker(self) -> None: + while True: + queued_command = await self._command_queue.get() + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) + if queued_command is None: + self._command_queue.task_done() + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) + return + + ingress_context_tokens = self._set_ingress_contextvars(queued_command) + loop = asyncio.get_running_loop() + started_at = loop.time() + handler_error: Exception | None = None + try: + async with self._processing_semaphore: + await queued_command.execute(self) + except Exception as exc: + handler_error = exc + if not queued_command.completion.done(): + queued_command.completion.set_exception(exc) + else: + if not queued_command.completion.done(): + queued_command.completion.set_result(None) + finally: + duration_ms = int((loop.time() - started_at) * 1000) + self._ingress_metrics_collector.on_command_finished( + queued_command.ingress_metadata, + IngressCommandResult( + duration_ms=max(duration_ms, 0), + error=handler_error, + ), + queue_depth=self._command_queue.qsize(), + ) + self._reset_contextvars(ingress_context_tokens) + self._command_queue.task_done() + self._ingress_metrics_collector.on_queue_depth(self._command_queue.qsize()) + + def _log_unhandled_task_exception(self, task: asyncio.Task[None]) -> None: + if task.cancelled(): + return + + error = task.exception() + if error is None: + return + if isinstance(error, BotCommandRejectedError): + logger.warning("Bot command has been rejected: {reason}", reason=error.reason) + return + + logger.opt(exception=error).error("Bot command task failed") + + def _build_ingress_command_metadata( + self, + bot_command: BotCommand, + ) -> IngressCommandMetadata: + if isinstance(bot_command, IncomingMessage): + command_name = self._get_command_name(bot_command.body) or "__default__" + return IngressCommandMetadata( + command_kind="incoming_message", + command_name=command_name, + ) + + return IngressCommandMetadata( + command_kind="system_event", + command_name=bot_command.__class__.__name__, + ) + + def _extract_request_id_from_command(self, bot_command: BotCommand) -> str | None: + sync_id = getattr(bot_command, "sync_id", None) + if sync_id is None: + return None + return str(sync_id) + + def _set_ingress_contextvars( + self, + queued_command: _QueuedBotCommand, + ) -> list[tuple[ContextVar[Any], Token[Any]]]: + context_tokens: list[tuple[ContextVar[Any], Token[Any]]] = [] + if queued_command.request_id is not None: + context_tokens.append( + (request_id_var, request_id_var.set(queued_command.request_id)), + ) + if queued_command.trace_id is not None: + context_tokens.append( + (trace_id_var, trace_id_var.set(queued_command.trace_id)), + ) + return context_tokens + + def _get_optional_context_value(self, context_var: ContextVar[str]) -> str | None: + try: + return context_var.get() + except LookupError: + return None diff --git a/pybotx/bot/healthcheck.py b/pybotx/bot/healthcheck.py new file mode 100644 index 00000000..ba505d02 --- /dev/null +++ b/pybotx/bot/healthcheck.py @@ -0,0 +1,291 @@ +import asyncio +import inspect +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Literal, TypeAlias +from collections.abc import Awaitable, Callable, Sequence + +if TYPE_CHECKING: + from fastapi import APIRouter, FastAPI + +HealthStatus: TypeAlias = Literal["ok", "degraded", "fail"] + +_ALLOWED_HEALTH_STATUSES = {"ok", "degraded", "fail"} + + +def _utc_now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def _format_utc_timestamp(now: datetime) -> str: + return now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", + "Z", + ) + + +@dataclass(frozen=True, slots=True) +class ReadinessCheckResult: + status: HealthStatus + reason: str | None = None + latency_ms: int | None = None + + def __post_init__(self) -> None: + if self.status not in _ALLOWED_HEALTH_STATUSES: + raise ValueError( + "Readiness check status should be one of: " + f"{', '.join(sorted(_ALLOWED_HEALTH_STATUSES))}", + ) + if self.latency_ms is not None and self.latency_ms < 0: + raise ValueError("Readiness check latency should be greater or equal to 0") + + +ReadinessCheckValue: TypeAlias = ReadinessCheckResult | HealthStatus | bool +ReadinessCheckFunc: TypeAlias = Callable[ + [], + ReadinessCheckValue | Awaitable[ReadinessCheckValue], +] + + +@dataclass(frozen=True, slots=True) +class ReadinessCheck: + name: str + check: ReadinessCheckFunc + critical: bool = True + timeout_seconds: float | None = 1.0 + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("Readiness check name should not be empty") + if self.timeout_seconds is not None and self.timeout_seconds <= 0: + raise ValueError("Readiness check timeout should be greater than 0") + + +class HealthcheckService: + def __init__( + self, + *, + readiness_checks: Sequence[ReadinessCheck] | None = None, + include_initialization_check: bool = True, + now_provider: Callable[[], datetime] | None = None, + ) -> None: + self._readiness_checks: list[ReadinessCheck] = [] + self._include_initialization_check = include_initialization_check + self._now_provider = now_provider or _utc_now + self._initialized = False + self._fatal_error: str | None = None + + for readiness_check in readiness_checks or (): + self.add_readiness_check(readiness_check) + + def add_readiness_check(self, readiness_check: ReadinessCheck) -> None: + if any(existing.name == readiness_check.name for existing in self._readiness_checks): + raise ValueError( + f"Readiness check with name `{readiness_check.name}` already exists", + ) + self._readiness_checks.append(readiness_check) + + def mark_initialized(self) -> None: + self._initialized = True + + def mark_uninitialized(self) -> None: + self._initialized = False + + def mark_fatal(self, reason: str | None = None) -> None: + self._fatal_error = reason or "fatal application state" + + def clear_fatal(self) -> None: + self._fatal_error = None + + async def get_liveness(self) -> tuple[HTTPStatus, dict[str, str]]: + timestamp = _format_utc_timestamp(self._now_provider()) + if self._fatal_error is not None: + return ( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"status": "fail", "timestamp": timestamp}, + ) + return HTTPStatus.OK, {"status": "ok", "timestamp": timestamp} + + async def get_readiness(self) -> tuple[HTTPStatus, dict[str, Any]]: + has_critical_failures = False + has_degraded_checks = False + checks_payload: dict[str, dict[str, Any]] = {} + + if self._include_initialization_check: + init_check_payload: dict[str, Any] = { + "status": "ok" if self._initialized else "fail", + } + if not self._initialized: + init_check_payload["reason"] = "application isn't initialized" + has_critical_failures = True + checks_payload["initialization"] = init_check_payload + + if self._fatal_error is not None: + checks_payload["application"] = { + "status": "fail", + "reason": self._fatal_error, + } + has_critical_failures = True + + if self._readiness_checks: + readiness_results = await asyncio.gather( + *( + self._execute_readiness_check(readiness_check) + for readiness_check in self._readiness_checks + ), + ) + + for readiness_check, readiness_result in readiness_results: + checks_payload[readiness_check.name] = self._result_to_payload( + readiness_result, + ) + + if readiness_result.status == "fail" and readiness_check.critical: + has_critical_failures = True + if readiness_result.status != "ok": + has_degraded_checks = True + + if has_critical_failures: + readiness_status: HealthStatus = "fail" + http_status = HTTPStatus.SERVICE_UNAVAILABLE + elif has_degraded_checks: + readiness_status = "degraded" + http_status = HTTPStatus.OK + else: + readiness_status = "ok" + http_status = HTTPStatus.OK + + payload: dict[str, Any] = { + "status": readiness_status, + "time": _format_utc_timestamp(self._now_provider()), + "checks": checks_payload, + } + + return http_status, payload + + async def _execute_readiness_check( + self, + readiness_check: ReadinessCheck, + ) -> tuple[ReadinessCheck, ReadinessCheckResult]: + started_at = time.perf_counter() + try: + raw_result_or_awaitable = readiness_check.check() + + if inspect.isawaitable(raw_result_or_awaitable): + if readiness_check.timeout_seconds is None: + raw_result = await raw_result_or_awaitable + else: + raw_result = await asyncio.wait_for( + raw_result_or_awaitable, + timeout=readiness_check.timeout_seconds, + ) + else: + raw_result = raw_result_or_awaitable + + elapsed_ms = int((time.perf_counter() - started_at) * 1000) + result = self._normalize_readiness_result(raw_result, elapsed_ms) + except asyncio.TimeoutError: + elapsed_ms = int((time.perf_counter() - started_at) * 1000) + result = ReadinessCheckResult( + status="fail", + reason="timeout", + latency_ms=elapsed_ms, + ) + except Exception as exc: + elapsed_ms = int((time.perf_counter() - started_at) * 1000) + result = ReadinessCheckResult( + status="fail", + reason=f"{type(exc).__name__}: {exc}", + latency_ms=elapsed_ms, + ) + + return readiness_check, result + + def _normalize_readiness_result( + self, + raw_result: ReadinessCheckValue, + elapsed_ms: int, + ) -> ReadinessCheckResult: + if isinstance(raw_result, ReadinessCheckResult): + if raw_result.latency_ms is not None: + return raw_result + return ReadinessCheckResult( + status=raw_result.status, + reason=raw_result.reason, + latency_ms=elapsed_ms, + ) + + if isinstance(raw_result, bool): + return ReadinessCheckResult( + status="ok" if raw_result else "fail", + latency_ms=elapsed_ms, + ) + + if raw_result in _ALLOWED_HEALTH_STATUSES: + return ReadinessCheckResult( + status=raw_result, + latency_ms=elapsed_ms, + ) + + raise TypeError( + "Unsupported readiness check result type. " + "Use bool, HealthStatus or ReadinessCheckResult.", + ) + + def _result_to_payload( + self, + readiness_result: ReadinessCheckResult, + ) -> dict[str, Any]: + result_payload: dict[str, Any] = { + "status": readiness_result.status, + "latency_ms": readiness_result.latency_ms, + } + if readiness_result.reason is not None: + result_payload["reason"] = readiness_result.reason + + return result_payload + + +def build_healthcheck_router( + *, + healthcheck: HealthcheckService | None = None, +) -> "APIRouter": + from fastapi import APIRouter + from fastapi.responses import JSONResponse + + healthcheck = healthcheck or HealthcheckService() + router = APIRouter() + + @router.get("/") + async def health_handler() -> JSONResponse: + status_code, payload = await healthcheck.get_liveness() + return JSONResponse(content=payload, status_code=status_code) + + @router.get("/ready") + async def readiness_handler() -> JSONResponse: + status_code, payload = await healthcheck.get_readiness() + return JSONResponse(content=payload, status_code=status_code) + + router.add_event_handler("startup", healthcheck.mark_initialized) + router.add_event_handler("shutdown", healthcheck.mark_uninitialized) + + return router + + +def setup_healthcheck( + app: "FastAPI", + *, + healthcheck: HealthcheckService | None = None, + prefix: str = "/health", +) -> HealthcheckService: + if not prefix.startswith("/"): + raise ValueError("Healthcheck prefix should start with '/'") + + healthcheck = healthcheck or HealthcheckService() + app.include_router( + build_healthcheck_router(healthcheck=healthcheck), + prefix=prefix, + ) + return healthcheck diff --git a/pybotx/bot/ingress_observability.py b/pybotx/bot/ingress_observability.py new file mode 100644 index 00000000..5e468e52 --- /dev/null +++ b/pybotx/bot/ingress_observability.py @@ -0,0 +1,276 @@ +import importlib +from dataclasses import dataclass +from threading import Lock +from typing import Any, Protocol, TypeAlias +from collections.abc import Sequence, Callable + + +@dataclass(frozen=True, slots=True) +class IngressCommandMetadata: + command_kind: str + command_name: str + + +@dataclass(frozen=True, slots=True) +class IngressCommandResult: + duration_ms: int + error: Exception | None = None + + +class BotIngressMetricsCollector(Protocol): + def on_queue_depth(self, queue_depth: int) -> None: ... # pragma: no cover + + def on_command_rejected( # pragma: no cover + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: ... + + def on_command_finished( # pragma: no cover + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: ... + + +class NoopIngressMetricsCollector(BotIngressMetricsCollector): + def on_queue_depth(self, queue_depth: int) -> None: + return None + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + return None + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + return None + + +OutcomeKey: TypeAlias = tuple[str, str, str] +ErrorKey: TypeAlias = tuple[str, str, str] +RejectedKey: TypeAlias = tuple[str, str, str] + + +@dataclass(frozen=True, slots=True) +class IngressMetricsSnapshot: + queue_depth: int + commands_total: int + errors_total: int + rejected_total: int + latency_sum_ms: int + command_outcomes: dict[OutcomeKey, int] + command_errors: dict[ErrorKey, int] + command_rejections: dict[RejectedKey, int] + + +class InMemoryIngressMetricsCollector(BotIngressMetricsCollector): + def __init__(self) -> None: + self._lock = Lock() + self._queue_depth = 0 + self._commands_total = 0 + self._errors_total = 0 + self._rejected_total = 0 + self._latency_sum_ms = 0 + self._command_outcomes: dict[OutcomeKey, int] = {} + self._command_errors: dict[ErrorKey, int] = {} + self._command_rejections: dict[RejectedKey, int] = {} + + def on_queue_depth(self, queue_depth: int) -> None: + with self._lock: + self._queue_depth = max(queue_depth, 0) + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + with self._lock: + self._queue_depth = max(queue_depth, 0) + self._rejected_total += 1 + key = ( + metadata.command_kind, + metadata.command_name, + reason, + ) + self._command_rejections[key] = self._command_rejections.get(key, 0) + 1 + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + with self._lock: + self._queue_depth = max(queue_depth, 0) + self._commands_total += 1 + duration_ms = max(result.duration_ms, 0) + self._latency_sum_ms += duration_ms + outcome = "ok" if result.error is None else "error" + outcome_key = ( + metadata.command_kind, + metadata.command_name, + outcome, + ) + self._command_outcomes[outcome_key] = self._command_outcomes.get(outcome_key, 0) + 1 + + if result.error is not None: + self._errors_total += 1 + error_key = ( + metadata.command_kind, + metadata.command_name, + type(result.error).__name__, + ) + self._command_errors[error_key] = self._command_errors.get(error_key, 0) + 1 + + def snapshot(self) -> IngressMetricsSnapshot: + with self._lock: + return IngressMetricsSnapshot( + queue_depth=self._queue_depth, + commands_total=self._commands_total, + errors_total=self._errors_total, + rejected_total=self._rejected_total, + latency_sum_ms=self._latency_sum_ms, + command_outcomes=dict(self._command_outcomes), + command_errors=dict(self._command_errors), + command_rejections=dict(self._command_rejections), + ) + + +ReasonNormalizer: TypeAlias = Callable[[str], str] + + +class PrometheusIngressMetricsCollector(BotIngressMetricsCollector): + DEFAULT_LATENCY_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1.0, + 2.5, + 5.0, + 10.0, + ) + + def __init__( + self, + *, + registry: Any | None = None, + metric_prefix: str = "pybotx_ingress", + latency_buckets: Sequence[float] | None = None, + reason_normalizer: ReasonNormalizer | None = None, + ) -> None: + try: + prometheus_client = importlib.import_module("prometheus_client") + except ModuleNotFoundError as exc: + raise RuntimeError( + "PrometheusIngressMetricsCollector requires `prometheus-client`. " + "Install with `uv add prometheus-client`.", + ) from exc + + counter_cls = getattr(prometheus_client, "Counter") + histogram_cls = getattr(prometheus_client, "Histogram") + gauge_cls = getattr(prometheus_client, "Gauge") + self._reason_normalizer = reason_normalizer or self._default_reason_normalizer + buckets = ( + tuple(latency_buckets) + if latency_buckets is not None + else self.DEFAULT_LATENCY_BUCKETS + ) + + self._commands_total = counter_cls( + f"{metric_prefix}_commands_total", + "Total number of processed ingress commands.", + ("command_kind", "command_name", "outcome"), + registry=registry, + ) + self._errors_total = counter_cls( + f"{metric_prefix}_errors_total", + "Total number of failed ingress command processing attempts.", + ("command_kind", "command_name", "error_type"), + registry=registry, + ) + self._rejected_total = counter_cls( + f"{metric_prefix}_rejected_total", + "Total number of rejected ingress commands.", + ("command_kind", "command_name", "reason"), + registry=registry, + ) + self._latency_seconds = histogram_cls( + f"{metric_prefix}_latency_seconds", + "Ingress command processing latency in seconds.", + ("command_kind", "command_name", "outcome"), + buckets=buckets, + registry=registry, + ) + self._queue_depth = gauge_cls( + f"{metric_prefix}_queue_depth", + "Current ingress queue depth.", + registry=registry, + ) + + def on_queue_depth(self, queue_depth: int) -> None: + self._queue_depth.set(max(queue_depth, 0)) + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + self._rejected_total.labels( + command_kind=metadata.command_kind, + command_name=metadata.command_name, + reason=self._reason_normalizer(reason), + ).inc() + self._queue_depth.set(max(queue_depth, 0)) + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + outcome = "ok" if result.error is None else "error" + self._commands_total.labels( + command_kind=metadata.command_kind, + command_name=metadata.command_name, + outcome=outcome, + ).inc() + self._latency_seconds.labels( + command_kind=metadata.command_kind, + command_name=metadata.command_name, + outcome=outcome, + ).observe(max(result.duration_ms, 0) / 1000.0) + if result.error is not None: + self._errors_total.labels( + command_kind=metadata.command_kind, + command_name=metadata.command_name, + error_type=type(result.error).__name__, + ).inc() + self._queue_depth.set(max(queue_depth, 0)) + + @staticmethod + def _default_reason_normalizer(reason: str) -> str: + return reason.split(":", maxsplit=1)[0] diff --git a/pybotx/cli.py b/pybotx/cli.py new file mode 100644 index 00000000..02939d1f --- /dev/null +++ b/pybotx/cli.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +from typing import Sequence + +from pybotx.scaffold import ( + CreateBotProjectOptions, + CreateCommandOptions, + CreateFSMFlowOptions, + CreatePortOptions, + CreateRepositoryOptions, + CreateServiceOptions, + CreateWidgetOptions, + CreateWidgetFlowOptions, + create_bot_project, + create_command_in_project, + create_fsm_flow_in_project, + create_port_in_project, + create_repository_in_project, + create_service_in_project, + create_widget_in_project, + create_widget_flow_in_project, +) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pybotx", + description="CLI utilities for pybotx projects.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + create_parser = subparsers.add_parser( + "create", + help="Create new assets managed by pybotx.", + ) + create_subparsers = create_parser.add_subparsers( + dest="create_command", + required=True, + ) + + create_bot_parser = create_subparsers.add_parser( + "bot", + help="Generate a production-ready pybotx bot project.", + ) + create_bot_parser.add_argument( + "target_dir", + nargs="?", + default=".", + help="Directory to generate the bot into. Defaults to the current directory.", + ) + create_bot_parser.add_argument( + "--template", + default="production-fastapi", + choices=("production-fastapi", "production-fastapi-fsm"), + help="Scaffold template to use.", + ) + create_bot_parser.add_argument( + "--project-name", + help="Explicit project name. Defaults to the target directory name.", + ) + create_bot_parser.add_argument( + "--package-name", + help="Python package name. Defaults to a normalized project name.", + ) + create_bot_parser.add_argument( + "--display-name", + help="Human-readable bot name used in README and default settings.", + ) + create_bot_parser.add_argument( + "--description", + help="Project description written into pyproject.toml.", + ) + create_bot_parser.add_argument( + "--otel-tracing", + action="store_true", + help="Enable OpenTelemetry tracing by default in generated settings.", + ) + create_bot_parser.add_argument( + "--no-docker", + action="store_true", + help="Skip Dockerfile and docker-compose.yml generation.", + ) + + create_command_parser = create_subparsers.add_parser( + "command", + help="Generate a command module inside a pybotx scaffold project.", + ) + create_command_parser.add_argument( + "command_name", + help="Command name, for example `ping-users` or `/ping-users`.", + ) + create_command_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_command_parser.add_argument( + "--description", + help="Command description for Bot status menu.", + ) + create_command_parser.add_argument( + "--response-text", + help="Default response text returned by the generated service.", + ) + + create_service_parser = create_subparsers.add_parser( + "service", + help="Generate an application service and register it in the DI container.", + ) + create_service_parser.add_argument( + "service_name", + help="Service name, for example `sync-users`.", + ) + create_service_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_service_parser.add_argument( + "--response-text", + help="Default response text returned by the generated service.", + ) + + create_port_parser = create_subparsers.add_parser( + "port", + help="Generate a domain port contract inside a pybotx scaffold project.", + ) + create_port_parser.add_argument( + "port_name", + help="Port name, for example `billing-gateway`.", + ) + create_port_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + + create_repository_parser = create_subparsers.add_parser( + "repository", + help="Generate a domain port + infrastructure repository stub and register it in DI.", + ) + create_repository_parser.add_argument( + "repository_name", + help="Repository name, for example `user-profile`.", + ) + create_repository_parser.add_argument( + "--port", + dest="port_name", + help="Existing domain port name to bind the generated repository to.", + ) + create_repository_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + + create_use_case_parser = create_subparsers.add_parser( + "use-case", + help="Alias of `create service` for teams that prefer use-case terminology.", + ) + create_use_case_parser.add_argument( + "service_name", + help="Use-case name, for example `sync-users`.", + ) + create_use_case_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_use_case_parser.add_argument( + "--response-text", + help="Default response text returned by the generated service.", + ) + + create_fsm_flow_parser = create_subparsers.add_parser( + "fsm-flow", + help="Generate an FSM flow module inside a pybotx FSM scaffold project.", + ) + create_fsm_flow_parser.add_argument( + "flow_name", + help="FSM flow name, for example `approval` or `order-approval`.", + ) + create_fsm_flow_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_fsm_flow_parser.add_argument( + "--command-name", + help="Explicit command name used to start the generated flow.", + ) + create_fsm_flow_parser.add_argument( + "--description", + help="Command description for Bot status menu.", + ) + create_fsm_flow_parser.add_argument( + "--initial-prompt", + help="Initial prompt sent when the generated FSM flow starts.", + ) + create_fsm_flow_parser.add_argument( + "--completion-text", + help="Static message sent when the generated FSM flow completes.", + ) + + create_widget_parser = create_subparsers.add_parser( + "widget", + help="Generate a widget command inside a pybotx scaffold project.", + ) + create_widget_parser.add_argument( + "widget_name", + help="Widget name, for example `deployment-approval`.", + ) + create_widget_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_widget_parser.add_argument( + "--kind", + default="confirm", + choices=( + "confirm", + "approval", + "select", + "search-select", + "multi-select", + "date-range", + ), + help="Generated widget kind.", + ) + create_widget_parser.add_argument( + "--command-name", + help="Explicit command name used to start the generated widget.", + ) + create_widget_parser.add_argument( + "--description", + help="Command description for Bot status menu.", + ) + + create_widget_flow_parser = create_subparsers.add_parser( + "widget-flow", + help="Generate a command + widget + follow-up service flow inside a pybotx scaffold project.", + ) + create_widget_flow_parser.add_argument( + "flow_name", + help="Widget flow name, for example `deployment-approval`.", + ) + create_widget_flow_parser.add_argument( + "--project-dir", + default=".", + help="Path inside the target pybotx scaffold project.", + ) + create_widget_flow_parser.add_argument( + "--kind", + default="confirm", + choices=( + "confirm", + "approval", + "select", + "search-select", + "multi-select", + "date-range", + ), + help="Generated widget flow kind.", + ) + create_widget_flow_parser.add_argument( + "--command-name", + help="Explicit command name used to start the generated widget flow.", + ) + create_widget_flow_parser.add_argument( + "--description", + help="Command description for Bot status menu.", + ) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.command == "create" and args.create_command == "bot": + return _handle_create_bot(args) + if args.command == "create" and args.create_command == "command": + return _handle_create_command(args) + if args.command == "create" and args.create_command == "port": + return _handle_create_port(args) + if args.command == "create" and args.create_command == "repository": + return _handle_create_repository(args) + if args.command == "create" and args.create_command in {"service", "use-case"}: + return _handle_create_service(args) + if args.command == "create" and args.create_command == "fsm-flow": + return _handle_create_fsm_flow(args) + if args.command == "create" and args.create_command == "widget": + return _handle_create_widget(args) + if args.command == "create" and args.create_command == "widget-flow": + return _handle_create_widget_flow(args) + + parser.error("Unknown command") + return 2 + + +def _handle_create_bot(args: argparse.Namespace) -> int: + try: + result = create_bot_project( + CreateBotProjectOptions( + target_dir=Path(args.target_dir), + template=args.template, + project_name=args.project_name, + package_name=args.package_name, + display_name=args.display_name, + description=args.description, + enable_otel_tracing=args.otel_tracing, + include_docker=not args.no_docker, + ), + ) + except (FileExistsError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created pybotx bot project in {result.root_dir}") + print(f"Template: {result.template}") + print(f"Package: {result.package_name}") + print("Next steps:") + print(f" cd {result.root_dir}") + print(" cp .env.example .env") + print(" uv sync") + print(f" uv run uvicorn {result.package_name}.main:app --reload") + return 0 + + +def _handle_create_command(args: argparse.Namespace) -> int: + try: + result = create_command_in_project( + CreateCommandOptions( + command_name=args.command_name, + project_dir=Path(args.project_dir), + description=args.description, + response_text=args.response_text, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created command {result.command_path} in {result.project_root}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_port(args: argparse.Namespace) -> int: + try: + result = create_port_in_project( + CreatePortOptions( + port_name=args.port_name, + project_dir=Path(args.project_dir), + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created port {result.port_name} in {result.project_root}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_repository(args: argparse.Namespace) -> int: + try: + result = create_repository_in_project( + CreateRepositoryOptions( + repository_name=args.repository_name, + project_dir=Path(args.project_dir), + port_name=args.port_name, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print( + f"Created repository {result.repository_name} bound to port " + f"{result.port_name} in {result.project_root}", + ) + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_service(args: argparse.Namespace) -> int: + try: + result = create_service_in_project( + CreateServiceOptions( + service_name=args.service_name, + project_dir=Path(args.project_dir), + response_text=args.response_text, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created application service {result.service_name} in {result.project_root}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_fsm_flow(args: argparse.Namespace) -> int: + try: + result = create_fsm_flow_in_project( + CreateFSMFlowOptions( + flow_name=args.flow_name, + project_dir=Path(args.project_dir), + command_name=args.command_name, + description=args.description, + initial_prompt=args.initial_prompt, + completion_text=args.completion_text, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created FSM flow {result.flow_name} in {result.project_root}") + print(f"Start command: {result.command_path}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_widget(args: argparse.Namespace) -> int: + try: + result = create_widget_in_project( + CreateWidgetOptions( + widget_name=args.widget_name, + project_dir=Path(args.project_dir), + kind=args.kind, + command_name=args.command_name, + description=args.description, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print( + f"Created {result.kind} widget {result.widget_name} in {result.project_root}", + ) + print(f"Start command: {result.command_path}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +def _handle_create_widget_flow(args: argparse.Namespace) -> int: + try: + result = create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name=args.flow_name, + project_dir=Path(args.project_dir), + kind=args.kind, + command_name=args.command_name, + description=args.description, + ), + ) + except (FileExistsError, FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"Created {result.kind} widget flow {result.flow_name} in {result.project_root}") + print(f"Start command: {result.command_path}") + print("Created files:") + for created_file in result.created_files: + print(f" {created_file}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pybotx/client/botx_method.py b/pybotx/client/botx_method.py index 4dc473cf..51b827d9 100644 --- a/pybotx/client/botx_method.py +++ b/pybotx/client/botx_method.py @@ -1,4 +1,5 @@ import json +import time from contextlib import asynccontextmanager from json.decoder import JSONDecodeError from typing import ( @@ -11,15 +12,26 @@ import httpx from mypy_extensions import Arg +from tenacity import ( + RetryCallState, +) from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.bot.callbacks.callback_manager import CallbackManager from pybotx.client.exceptions.base import BaseClientError from pybotx.client.exceptions.callbacks import BotXMethodFailedCallbackReceivedError from pybotx.client.exceptions.http import ( + InvalidBotXResponseError, InvalidBotXResponsePayloadError, InvalidBotXStatusCodeError, ) +from pybotx.client.http_config import TenacityRetryStrategy +from pybotx.client.observability import ( + BotXRequestMetadata, + BotXRequestObserver, + BotXRequestResult, + BotXRetryEvent, +) from pybotx.logger import logger, pformat_jsonable_obj, trim_file_data_in_outgoing_json from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.method_callbacks import ( @@ -37,6 +49,14 @@ ] ErrorCallbackHandlers = Mapping[str, CallbackExceptionHandler] TBotXAPIModel = TypeVar("TBotXAPIModel", bound=VerifiedPayloadBaseModel) +RequestCall = Callable[..., Awaitable[httpx.Response]] +RequestDecorator = Callable[[RequestCall], RequestCall] + + +class RetryableBotXStatusCodeError(Exception): + def __init__(self, response: httpx.Response) -> None: + super().__init__(response.status_code) + self.response = response def response_exception_thrower( @@ -62,6 +82,7 @@ def factory(callback: BotAPIMethodFailedCallback) -> NoReturn: class BotXMethod: status_handlers: StatusHandlers = {} error_callback_handlers: ErrorCallbackHandlers = {} + operation_name: str | None = None def __init__( self, @@ -110,7 +131,14 @@ def _verify_and_extract_api_model( async def _botx_method_call(self, *args: Any, **kwargs: Any) -> httpx.Response: self._log_outgoing_request(*args, **kwargs) - response = await self._httpx_client.request(*args, **kwargs) + method, url = args + request = self._decorate_request( + self._httpx_client.request, + method=method, + url=url, + is_streaming=False, + ) + response = await request(*args, **kwargs) await self._raise_for_status(response) return response @@ -123,9 +151,19 @@ async def _botx_method_stream( ) -> AsyncGenerator[httpx.Response, None]: self._log_outgoing_request(*args, **kwargs) - async with self._httpx_client.stream(*args, **kwargs) as response: + method, url = args + stream_request = self._decorate_request( + self._streaming_request, + method=method, + url=url, + is_streaming=True, + ) + response = await stream_request(*args, **kwargs) + try: await self._raise_for_status(response) yield response + finally: + await response.aclose() async def _raise_for_status(self, response: httpx.Response) -> None: handler = self.status_handlers.get(response.status_code) @@ -181,6 +219,279 @@ async def _process_callback( return callback + async def _streaming_request(self, *args: Any, **kwargs: Any) -> httpx.Response: + method, url = args + request = self._httpx_client.build_request(method, url, **kwargs) + return await self._httpx_client.send(request, stream=True) + + async def _retryable_request_call( + self, + request_call: RequestCall, + retryable_status_codes: frozenset[int], + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + response = await request_call(*args, **kwargs) + if response.status_code not in retryable_status_codes: + return response + + if not response.is_closed: + await response.aread() + await response.aclose() + raise RetryableBotXStatusCodeError(response) + + def _decorate_request( + self, + request_call: RequestCall, + *, + method: str, + url: str, + is_streaming: bool, + ) -> RequestCall: + decorated = request_call + for decorator in self._request_decorators( + method=method, + url=url, + is_streaming=is_streaming, + ): + decorated = decorator(decorated) + return decorated + + def _request_decorators( + self, + *, + method: str, + url: str, + is_streaming: bool, + ) -> list[RequestDecorator]: + decorators: list[RequestDecorator] = [] + metadata = self._build_request_metadata( + method=method, + url=url, + is_streaming=is_streaming, + ) + retry_policy = self._bot_accounts_storage.get_retry_policy() + retry_request_policy = self._bot_accounts_storage.get_retry_request_policy() + if ( + retry_policy is not None + and retry_request_policy is not None + and retry_request_policy.should_retry(metadata) + ): + decorators.append(self._build_retry_decorator(metadata=metadata)) + + observers = tuple(self._bot_accounts_storage.iter_request_observers()) + if observers: + decorators.append( + self._build_observability_decorator( + metadata=metadata, + observers=observers, + ), + ) + + return decorators + + def _build_observability_decorator( + self, + *, + metadata: BotXRequestMetadata, + observers: tuple[BotXRequestObserver, ...], + ) -> RequestDecorator: + def decorator(request_call: RequestCall) -> RequestCall: + async def wrapped(*args: Any, **kwargs: Any) -> httpx.Response: + started_at = time.perf_counter() + self._notify_request_start(observers, metadata) + try: + response = await request_call(*args, **kwargs) + except Exception as exc: + duration_ms = int((time.perf_counter() - started_at) * 1000) + status_code = ( + exc.response.status_code + if isinstance(exc, InvalidBotXResponseError) + else None + ) + self._notify_request_finish( + observers, + metadata, + BotXRequestResult( + status_code=status_code, + duration_ms=duration_ms, + error=exc, + ), + ) + raise + + duration_ms = int((time.perf_counter() - started_at) * 1000) + self._notify_request_finish( + observers, + metadata, + BotXRequestResult( + status_code=response.status_code, + duration_ms=duration_ms, + ), + ) + return response + + return wrapped + + return decorator + + def _build_retry_decorator( + self, + *, + metadata: BotXRequestMetadata, + ) -> RequestDecorator: + retry_policy = self._bot_accounts_storage.get_retry_policy() + assert retry_policy is not None + retry_strategy = ( + self._bot_accounts_storage.get_retry_strategy() or TenacityRetryStrategy() + ) + retry_exceptions = ( + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, + RetryableBotXStatusCodeError, + ) + observers = tuple(self._bot_accounts_storage.iter_request_observers()) + + def decorator(request_call: RequestCall) -> RequestCall: + async def wrapped(*args: Any, **kwargs: Any) -> httpx.Response: + retrying = retry_strategy.build_retrying( + retry_policy=retry_policy, + retry_exceptions=retry_exceptions, + before_sleep=self._build_before_sleep_logger( + method=metadata.method, + url=metadata.url, + max_attempts=retry_policy.max_attempts, + metadata=metadata, + observers=observers, + ), + ) + try: + return await retrying( + self._retryable_request_call, + request_call, + retry_policy.retryable_status_codes, + *args, + **kwargs, + ) + except RetryableBotXStatusCodeError as exc: + return exc.response + + return wrapped + + return decorator + + def _build_request_metadata( + self, + *, + method: str, + url: str, + is_streaming: bool, + ) -> BotXRequestMetadata: + return BotXRequestMetadata( + method=method, + url=url, + operation_name=self._get_operation_name(), + is_streaming=is_streaming, + ) + + def _get_operation_name(self) -> str: + return type(self).operation_name or type(self).__name__ + + def _build_before_sleep_logger( + self, + *, + method: str, + url: str, + max_attempts: int, + metadata: BotXRequestMetadata, + observers: tuple[BotXRequestObserver, ...], + ) -> Callable[[RetryCallState], None]: + def callback(retry_state: RetryCallState) -> None: + sleep_seconds = ( + retry_state.next_action.sleep + if retry_state.next_action is not None + else 0.0 + ) + reason = self._format_retry_reason(retry_state) + logger.warning( + "Retrying BotX request {method} {url}. " + "Attempt {attempt}/{max_attempts}. " + "Sleeping {sleep_seconds:.3f}s. " + "Reason: {reason}", + method=method, + url=url, + attempt=retry_state.attempt_number, + max_attempts=max_attempts, + sleep_seconds=sleep_seconds, + reason=reason, + ) + retry_event = BotXRetryEvent( + attempt=retry_state.attempt_number, + max_attempts=max_attempts, + sleep_seconds=sleep_seconds, + reason=reason, + ) + self._notify_request_retry( + observers=observers, + metadata=metadata, + retry_event=retry_event, + ) + + return callback + + def _format_retry_reason(self, retry_state: RetryCallState) -> str: + if retry_state.outcome is None or not retry_state.outcome.failed: + return "unknown" + + exc = retry_state.outcome.exception() + if exc is None: + return "unknown" + if isinstance(exc, RetryableBotXStatusCodeError): + return f"status_code={exc.response.status_code}" + return f"{type(exc).__name__}: {exc}" + + def _notify_request_start( + self, + observers: tuple[BotXRequestObserver, ...], + metadata: BotXRequestMetadata, + ) -> None: + for observer in observers: + try: + observer.on_request_start(metadata) + except Exception: + logger.opt(exception=True).warning( + "Request observer failed in on_request_start", + ) + + def _notify_request_retry( + self, + observers: tuple[BotXRequestObserver, ...], + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + for observer in observers: + try: + observer.on_request_retry(metadata, retry_event) + except Exception: + logger.opt(exception=True).warning( + "Request observer failed in on_request_retry", + ) + + def _notify_request_finish( + self, + observers: tuple[BotXRequestObserver, ...], + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + for observer in observers: + try: + observer.on_request_finish(metadata, result) + except Exception: + logger.opt(exception=True).warning( + "Request observer failed in on_request_finish", + ) + def _log_outgoing_request( self, *args: Any, diff --git a/pybotx/client/chats_api/create_chat.py b/pybotx/client/chats_api/create_chat.py index 640c44d5..aa24085d 100644 --- a/pybotx/client/chats_api/create_chat.py +++ b/pybotx/client/chats_api/create_chat.py @@ -39,7 +39,7 @@ class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel): @model_validator(mode="before") def _convert_chat_type(cls, values: dict[str, Any]) -> dict[str, Any]: chat_type = values.get("chat_type") - if isinstance(chat_type, APIChatTypes) and chat_type == APIChatTypes.VOEX_CALL: + if chat_type in {APIChatTypes.VOEX_CALL, ChatTypes.VOEX_CALL}: raise ValueError("Bot cannot create a chat of type 'voex_call'") if isinstance(chat_type, ChatTypes): values["chat_type"] = convert_chat_type_from_domain(chat_type) diff --git a/pybotx/client/exceptions/users.py b/pybotx/client/exceptions/users.py index c6fc913f..a92a4310 100644 --- a/pybotx/client/exceptions/users.py +++ b/pybotx/client/exceptions/users.py @@ -11,3 +11,7 @@ class InvalidProfileDataError(BaseClientError): class NoUserKindSelectedError(BaseClientError): """No user kind selected.""" + + +class UserProfileUpdateUnavailableError(BaseClientError): + """User profile update service is unavailable.""" diff --git a/pybotx/client/files_api/upload_file.py b/pybotx/client/files_api/upload_file.py index 585edd4a..0b7936a0 100644 --- a/pybotx/client/files_api/upload_file.py +++ b/pybotx/client/files_api/upload_file.py @@ -46,6 +46,7 @@ def to_domain(self) -> File: class UploadFileMethod(AuthorizedBotXMethod): + operation_name = "FilesUploadFileMethod" status_handlers = { **AuthorizedBotXMethod.status_handlers, 404: response_exception_thrower(ChatNotFoundError), diff --git a/pybotx/client/http_config.py b/pybotx/client/http_config.py new file mode 100644 index 00000000..9f97a055 --- /dev/null +++ b/pybotx/client/http_config.py @@ -0,0 +1,436 @@ +from dataclasses import dataclass, field +from enum import Enum +import re +from typing import Protocol +from collections.abc import Callable, Collection +from urllib.parse import urlsplit + +import httpx +from tenacity import ( + AsyncRetrying, + RetryCallState, + retry_if_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) + +from pybotx.client.observability import BotXRequestMetadata +from pybotx.constants import ( + BOTX_HTTP_CONNECT_TIMEOUT_SECONDS, + BOTX_HTTP_KEEPALIVE_EXPIRY_SECONDS, + BOTX_HTTP_MAX_CONNECTIONS, + BOTX_HTTP_MAX_KEEPALIVE_CONNECTIONS, + BOTX_HTTP_POOL_TIMEOUT_SECONDS, + BOTX_HTTP_READ_TIMEOUT_SECONDS, + BOTX_HTTP_RETRY_ATTEMPTS, + BOTX_HTTP_RETRY_INITIAL_DELAY_SECONDS, + BOTX_HTTP_RETRY_JITTER_SECONDS, + BOTX_HTTP_RETRY_MAX_DELAY_SECONDS, + BOTX_HTTP_WRITE_TIMEOUT_SECONDS, +) + +DEFAULT_RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) +RetryExceptionTypes = tuple[type[BaseException], ...] +BeforeSleepHandler = Callable[[RetryCallState], None] +AllowedRequestPattern = tuple[str, str] +PathNormalizer = Callable[[str], str] + +_UUID_PATH_SEGMENT_RE = re.compile( + r"/[0-9a-fA-F]{8}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{12}(?=/|$)", +) + + +class _StrEnum(str, Enum): + def __str__(self) -> str: + return str(self.value) + + +class BotXOperation(_StrEnum): + ADD_ADMIN = "AddAdminMethod" + ADD_STICKER = "AddStickerMethod" + ADD_USER = "AddUserMethod" + BOTS_LIST = "BotsListMethod" + CHAT_INFO = "ChatInfoMethod" + COLLECT_BOT_FUNCTION = "CollectBotFunctionMethod" + CREATE_CHAT = "CreateChatMethod" + CREATE_CHAT_LINK = "CreateChatLinkMethod" + CREATE_STICKER_PACK = "CreateStickerPackMethod" + CREATE_THREAD = "CreateThreadMethod" + DELETE_EVENT = "DeleteEventMethod" + DELETE_STICKER = "DeleteStickerMethod" + DELETE_STICKER_PACK = "DeleteStickerPackMethod" + DIRECT_NOTIFICATION = "DirectNotificationMethod" + DIRECT_NOTIFICATION_SYNC = "DirectNotificationSyncMethod" + DISABLE_STEALTH = "DisableStealthMethod" + DOWNLOAD_FILE = "DownloadFileMethod" + EDIT_EVENT = "EditEventMethod" + EDIT_STICKER_PACK = "EditStickerPackMethod" + FILES_UPLOAD_FILE = "FilesUploadFileMethod" + GET_STICKER = "GetStickerMethod" + GET_STICKER_PACK = "GetStickerPackMethod" + GET_STICKER_PACKS = "GetStickerPacksMethod" + GET_TOKEN = "GetTokenMethod" + INTERNAL_BOT_NOTIFICATION = "InternalBotNotificationMethod" + LIST_CHATS = "ListChatsMethod" + MESSAGE_STATUS = "MessageStatusMethod" + PERSONAL_CHAT = "PersonalChatMethod" + PIN_MESSAGE = "PinMessageMethod" + REFRESH_ACCESS_TOKEN = "RefreshAccessTokenMethod" + REMOVE_USER = "RemoveUserMethod" + REPLY_EVENT = "ReplyEventMethod" + SEARCH_USER_BY_EMAIL_GET = "SearchUserByEmailMethod" + SEARCH_USER_BY_EMAIL_POST = "SearchUserByEmailPostMethod" + SEARCH_USER_BY_EMAILS = "SearchUserByEmailsMethod" + SEARCH_USER_BY_HUID = "SearchUserByHUIDMethod" + SEARCH_USER_BY_LOGIN = "SearchUserByLoginMethod" + SEARCH_USER_BY_OTHER_ID = "SearchUserByOtherIdMethod" + SET_STEALTH = "SetStealthMethod" + SMARTAPP_CUSTOM_NOTIFICATION = "SmartAppCustomNotificationMethod" + SMARTAPP_EVENT = "SmartAppEventMethod" + SMARTAPP_MANIFEST = "SmartAppManifestMethod" + SMARTAPP_NOTIFICATION = "SmartAppNotificationMethod" + SMARTAPP_UNREAD_COUNTER = "SmartAppUnreadCounterMethod" + SMARTAPPS_LIST = "SmartAppsListMethod" + SMARTAPPS_UPLOAD_FILE = "SmartAppsUploadFileMethod" + STOP_TYPING_EVENT = "StopTypingEventMethod" + TYPING_EVENT = "TypingEventMethod" + UNPIN_MESSAGE = "UnpinMessageMethod" + UPDATE_USERS_PROFILE = "UpdateUsersProfileMethod" + USERS_AS_CSV = "UsersAsCSVMethod" + + +OperationName = str | BotXOperation + + +KNOWN_SAFE_BOTX_OPERATIONS = frozenset( + { + BotXOperation.BOTS_LIST, + BotXOperation.CHAT_INFO, + BotXOperation.DOWNLOAD_FILE, + BotXOperation.GET_STICKER, + BotXOperation.GET_STICKER_PACK, + BotXOperation.GET_STICKER_PACKS, + BotXOperation.LIST_CHATS, + BotXOperation.MESSAGE_STATUS, + BotXOperation.PERSONAL_CHAT, + BotXOperation.SEARCH_USER_BY_EMAIL_GET, + BotXOperation.SEARCH_USER_BY_EMAIL_POST, + BotXOperation.SEARCH_USER_BY_EMAILS, + BotXOperation.SEARCH_USER_BY_HUID, + BotXOperation.SEARCH_USER_BY_LOGIN, + BotXOperation.SEARCH_USER_BY_OTHER_ID, + BotXOperation.SMARTAPPS_LIST, + BotXOperation.USERS_AS_CSV, + }, +) +_KNOWN_SAFE_OPERATION_NAMES = frozenset(operation.value for operation in KNOWN_SAFE_BOTX_OPERATIONS) + + +def build_default_httpx_timeout() -> httpx.Timeout: + return httpx.Timeout( + connect=BOTX_HTTP_CONNECT_TIMEOUT_SECONDS, + read=BOTX_HTTP_READ_TIMEOUT_SECONDS, + write=BOTX_HTTP_WRITE_TIMEOUT_SECONDS, + pool=BOTX_HTTP_POOL_TIMEOUT_SECONDS, + ) + + +def build_default_httpx_limits() -> httpx.Limits: + return httpx.Limits( + max_connections=BOTX_HTTP_MAX_CONNECTIONS, + max_keepalive_connections=BOTX_HTTP_MAX_KEEPALIVE_CONNECTIONS, + keepalive_expiry=BOTX_HTTP_KEEPALIVE_EXPIRY_SECONDS, + ) + + +def _normalize_operation_name(operation_name: OperationName) -> str: + if isinstance(operation_name, BotXOperation): + return operation_name.value + return operation_name + + +@dataclass(frozen=True, slots=True) +class BotXRetryPolicy: + max_attempts: int = BOTX_HTTP_RETRY_ATTEMPTS + initial_delay_seconds: float = BOTX_HTTP_RETRY_INITIAL_DELAY_SECONDS + max_delay_seconds: float = BOTX_HTTP_RETRY_MAX_DELAY_SECONDS + jitter_seconds: float = BOTX_HTTP_RETRY_JITTER_SECONDS + retryable_status_codes: frozenset[int] = field( + default_factory=lambda: DEFAULT_RETRYABLE_STATUS_CODES, + ) + + def __post_init__(self) -> None: + if self.max_attempts < 1: + raise ValueError("Retry max attempts should be greater than 0") + if self.initial_delay_seconds < 0: + raise ValueError("Retry initial delay should be greater or equal to 0") + if self.max_delay_seconds < 0: + raise ValueError("Retry max delay should be greater or equal to 0") + if self.max_delay_seconds < self.initial_delay_seconds: + raise ValueError( + "Retry max delay should be greater or equal to initial delay", + ) + if self.jitter_seconds < 0: + raise ValueError("Retry jitter should be greater or equal to 0") + + +class BotXRetryStrategy(Protocol): + def build_retrying( # pragma: no cover + self, + *, + retry_policy: BotXRetryPolicy, + retry_exceptions: RetryExceptionTypes, + before_sleep: BeforeSleepHandler, + ) -> AsyncRetrying: ... + + +class BotXRetryRequestPolicy(Protocol): + """Decides whether a specific BotX request may be retried automatically.""" + + def should_retry( # pragma: no cover + self, + metadata: BotXRequestMetadata, + ) -> bool: ... + + +@dataclass(frozen=True, slots=True) +class RetryAllBotXRequestsPolicy(BotXRetryRequestPolicy): + """Retries every request matched by retry_policy.""" + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + return True + + +class OperationNameAllowlistBotXRetryRequestPolicy(BotXRetryRequestPolicy): + """Retries only requests whose operation_name matches the allowlist.""" + + def __init__(self, *, operation_names: Collection[OperationName]) -> None: + self._operation_names = frozenset( + _normalize_operation_name(operation_name) + for operation_name in operation_names + ) + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + operation_name = metadata.operation_name + if operation_name is None: + return False + return operation_name in self._operation_names + + def get_operation_names(self) -> frozenset[str]: + return self._operation_names + + +class KnownSafeBotXRetryRequestPolicy(BotXRetryRequestPolicy): + """Retries only built-in pybotx operations from the known-safe catalog.""" + + def __init__( + self, + *, + extra_operations: Collection[OperationName] | None = None, + ) -> None: + operation_names = set(_KNOWN_SAFE_OPERATION_NAMES) + if extra_operations is not None: + operation_names.update( + _normalize_operation_name(operation_name) + for operation_name in extra_operations + ) + self._operation_policy = OperationNameAllowlistBotXRetryRequestPolicy( + operation_names=operation_names, + ) + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + return self._operation_policy.should_retry(metadata) + + def get_operation_names(self) -> frozenset[str]: + return self._operation_policy.get_operation_names() + + +class PathAllowlistBotXRetryRequestPolicy(BotXRetryRequestPolicy): + """Retries only requests that match the configured method/path allowlist.""" + + def __init__( + self, + *, + allowed_requests: Collection[AllowedRequestPattern], + path_normalizer: PathNormalizer | None = None, + ) -> None: + self._allowed_requests = frozenset( + (method.upper(), path) + for method, path in allowed_requests + ) + self._path_normalizer = path_normalizer or self._default_path_normalizer + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + normalized_path = self._path_normalizer(metadata.url) + return (metadata.method.upper(), normalized_path) in self._allowed_requests + + def get_allowed_requests(self) -> frozenset[AllowedRequestPattern]: + return self._allowed_requests + + @staticmethod + def _default_path_normalizer(url: str) -> str: + parsed = urlsplit(url) + path = parsed.path or "/" + return _UUID_PATH_SEGMENT_RE.sub("/{uuid}", path) + + +class _MethodAllowlistBotXRetryRequestPolicy(BotXRetryRequestPolicy): + def __init__(self, *, methods: Collection[str]) -> None: + self._methods = frozenset(method.upper() for method in methods) + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + return metadata.method.upper() in self._methods + + +class AnyOfBotXRetryRequestPolicy(BotXRetryRequestPolicy): + """Retries when any nested policy allows retry.""" + + def __init__(self, *, policies: Collection[BotXRetryRequestPolicy]) -> None: + self._policies = tuple(policies) + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + return any(policy.should_retry(metadata) for policy in self._policies) + + def get_policies(self) -> tuple[BotXRetryRequestPolicy, ...]: + return self._policies + + +class SafeBotXRetryRequestPolicy(BotXRetryRequestPolicy): + """Safe default for BotX: retry only read-only requests.""" + + DEFAULT_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + DEFAULT_SAFE_REQUESTS = frozenset( + { + ("POST", "/api/v3/botx/users/by_email"), + }, + ) + + def __init__( + self, + *, + safe_methods: Collection[str] | None = None, + extra_safe_requests: Collection[AllowedRequestPattern] | None = None, + extra_safe_operation_names: Collection[str] | None = None, + path_normalizer: PathNormalizer | None = None, + ) -> None: + methods = safe_methods or self.DEFAULT_SAFE_METHODS + allowlist = set(self.DEFAULT_SAFE_REQUESTS) + if extra_safe_requests is not None: + allowlist.update( + (method.upper(), path) + for method, path in extra_safe_requests + ) + policies: list[BotXRetryRequestPolicy] = [ + _MethodAllowlistBotXRetryRequestPolicy(methods=methods), + PathAllowlistBotXRetryRequestPolicy( + allowed_requests=allowlist, + path_normalizer=path_normalizer, + ), + ] + if extra_safe_operation_names is not None: + policies.append( + OperationNameAllowlistBotXRetryRequestPolicy( + operation_names=extra_safe_operation_names, + ), + ) + self._policy = AnyOfBotXRetryRequestPolicy(policies=policies) + + def should_retry(self, metadata: BotXRequestMetadata) -> bool: + return self._policy.should_retry(metadata) + + +def iter_retry_request_policy_warnings( + policy: BotXRetryRequestPolicy, +) -> tuple[str, ...]: + warnings: list[str] = [] + _collect_retry_request_policy_warnings(policy, warnings) + return tuple(dict.fromkeys(warnings)) + + +def _collect_retry_request_policy_warnings( + policy: BotXRetryRequestPolicy, + warnings: list[str], +) -> None: + if isinstance(policy, RetryAllBotXRequestsPolicy): + warnings.append( + "RetryAllBotXRequestsPolicy retries all BotX requests, including " + "write operations. Verify idempotency or external deduplication.", + ) + return + + if isinstance(policy, KnownSafeBotXRetryRequestPolicy): + unsafe_operations = sorted( + operation_name + for operation_name in policy.get_operation_names() + if operation_name not in _KNOWN_SAFE_OPERATION_NAMES + ) + if unsafe_operations: + warnings.append( + "KnownSafeBotXRetryRequestPolicy has extra operations outside the " + f"known-safe catalog: {', '.join(unsafe_operations)}. Verify " + "idempotency manually.", + ) + return + + if isinstance(policy, OperationNameAllowlistBotXRetryRequestPolicy): + unsafe_operations = sorted( + operation_name + for operation_name in policy.get_operation_names() + if operation_name not in _KNOWN_SAFE_OPERATION_NAMES + ) + if unsafe_operations: + warnings.append( + "OperationNameAllowlistBotXRetryRequestPolicy allows operations " + f"outside the known-safe catalog: {', '.join(unsafe_operations)}. " + "Verify idempotency manually.", + ) + return + + if isinstance(policy, PathAllowlistBotXRetryRequestPolicy): + risky_requests = sorted( + f"{method} {path}" + for method, path in policy.get_allowed_requests() + if method.upper() not in SafeBotXRetryRequestPolicy.DEFAULT_SAFE_METHODS + and (method.upper(), path) not in SafeBotXRetryRequestPolicy.DEFAULT_SAFE_REQUESTS + ) + if risky_requests: + warnings.append( + "PathAllowlistBotXRetryRequestPolicy allows requests outside the " + f"known-safe set: {', '.join(risky_requests)}. Verify idempotency " + "manually.", + ) + return + + if isinstance(policy, SafeBotXRetryRequestPolicy): + _collect_retry_request_policy_warnings(policy._policy, warnings) + return + + if isinstance(policy, AnyOfBotXRetryRequestPolicy): + for nested_policy in policy.get_policies(): + _collect_retry_request_policy_warnings(nested_policy, warnings) + + +@dataclass(frozen=True, slots=True) +class TenacityRetryStrategy(BotXRetryStrategy): + def build_retrying( + self, + *, + retry_policy: BotXRetryPolicy, + retry_exceptions: RetryExceptionTypes, + before_sleep: BeforeSleepHandler, + ) -> AsyncRetrying: + return AsyncRetrying( + stop=stop_after_attempt(retry_policy.max_attempts), + wait=wait_exponential_jitter( + initial=retry_policy.initial_delay_seconds, + max=retry_policy.max_delay_seconds, + jitter=retry_policy.jitter_seconds, + ), + retry=retry_if_exception_type(retry_exceptions), + before_sleep=before_sleep, + reraise=True, + ) diff --git a/pybotx/client/observability.py b/pybotx/client/observability.py new file mode 100644 index 00000000..1852e8e1 --- /dev/null +++ b/pybotx/client/observability.py @@ -0,0 +1,335 @@ +from dataclasses import dataclass +import importlib +import re +from contextvars import ContextVar +from collections.abc import Callable, Sequence +from typing import Any, Protocol, TypeAlias +from urllib.parse import urlsplit + + +@dataclass(frozen=True, slots=True) +class BotXRequestMetadata: + method: str + url: str + operation_name: str | None = None + is_streaming: bool = False + + +@dataclass(frozen=True, slots=True) +class BotXRetryEvent: + attempt: int + max_attempts: int + sleep_seconds: float + reason: str + + +@dataclass(frozen=True, slots=True) +class BotXRequestResult: + status_code: int | None + duration_ms: int + error: Exception | None = None + + +class BotXRequestObserver(Protocol): + def on_request_start( # pragma: no cover + self, + metadata: BotXRequestMetadata, + ) -> None: ... + + def on_request_retry( # pragma: no cover + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: ... + + def on_request_finish( # pragma: no cover + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: ... + + +PathNormalizer: TypeAlias = Callable[[str], str] +ReasonNormalizer: TypeAlias = Callable[[str], str] +SpanEnricher: TypeAlias = Callable[[Any, BotXRequestMetadata], None] + + +_UUID_PATH_SEGMENT_RE = re.compile( + r"/[0-9a-fA-F]{8}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{12}(?=/|$)", +) + + +class PrometheusMetricsCollector(BotXRequestObserver): + DEFAULT_LATENCY_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1.0, + 2.5, + 5.0, + 10.0, + ) + + def __init__( + self, + *, + registry: Any | None = None, + metric_prefix: str = "pybotx_botx", + latency_buckets: Sequence[float] | None = None, + path_normalizer: PathNormalizer | None = None, + reason_normalizer: ReasonNormalizer | None = None, + ) -> None: + try: + prometheus_client = importlib.import_module("prometheus_client") + except ModuleNotFoundError as exc: + raise RuntimeError( + "PrometheusMetricsCollector requires `prometheus-client`. " + "Install with `uv add prometheus-client`.", + ) from exc + + counter_cls = getattr(prometheus_client, "Counter") + histogram_cls = getattr(prometheus_client, "Histogram") + buckets = ( + tuple(latency_buckets) + if latency_buckets is not None + else self.DEFAULT_LATENCY_BUCKETS + ) + self._path_normalizer = path_normalizer or self._default_path_normalizer + self._reason_normalizer = ( + reason_normalizer or self._default_reason_normalizer + ) + + self._requests_total = counter_cls( + f"{metric_prefix}_requests_total", + "Total number of outgoing BotX requests.", + ("method", "path", "status", "outcome"), + registry=registry, + ) + self._request_errors_total = counter_cls( + f"{metric_prefix}_request_errors_total", + "Total number of failed outgoing BotX requests.", + ("method", "path", "status", "error_type"), + registry=registry, + ) + self._request_retries_total = counter_cls( + f"{metric_prefix}_request_retries_total", + "Total number of BotX request retries.", + ("method", "path", "reason"), + registry=registry, + ) + self._request_latency_seconds = histogram_cls( + f"{metric_prefix}_request_latency_seconds", + "Latency of outgoing BotX requests in seconds.", + ("method", "path", "status"), + buckets=buckets, + registry=registry, + ) + + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + # No in-flight metric by default. Start hook is preserved for future extensions. + return None + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + self._request_retries_total.labels( + method=metadata.method, + path=self._path_label(metadata.url), + reason=self._reason_label(retry_event.reason), + ).inc() + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + path = self._path_label(metadata.url) + status = self._status_label(result.status_code) + outcome = "ok" if result.error is None else "error" + + self._requests_total.labels( + method=metadata.method, + path=path, + status=status, + outcome=outcome, + ).inc() + self._request_latency_seconds.labels( + method=metadata.method, + path=path, + status=status, + ).observe(max(result.duration_ms, 0) / 1000.0) + + if result.error is not None: + self._request_errors_total.labels( + method=metadata.method, + path=path, + status=status, + error_type=type(result.error).__name__, + ).inc() + + def _path_label(self, url: str) -> str: + return self._path_normalizer(url) + + def _status_label(self, status_code: int | None) -> str: + if status_code is None: + return "none" + return str(status_code) + + def _reason_label(self, reason: str) -> str: + return self._reason_normalizer(reason) + + @staticmethod + def _default_path_normalizer(url: str) -> str: + parsed = urlsplit(url) + path = parsed.path or "/" + return _UUID_PATH_SEGMENT_RE.sub("/{uuid}", path) + + @staticmethod + def _default_reason_normalizer(reason: str) -> str: + if reason.startswith("status_code="): + return reason + # Keep retry reason low-cardinality for metrics (exception class expected). + return reason.split(":", maxsplit=1)[0] + + +class OpenTelemetryTracingCollector(BotXRequestObserver): + _span_stack_var: ContextVar[tuple[Any, ...]] = ContextVar( + "pybotx_open_telemetry_spans", + default=(), + ) + + def __init__( + self, + *, + tracer_name: str = "pybotx.botx", + span_name_template: str = "{method} {path}", + tracer_provider: Any | None = None, + span_enricher: SpanEnricher | None = None, + ) -> None: + try: + trace_api = importlib.import_module("opentelemetry.trace") + except ModuleNotFoundError as exc: + raise RuntimeError( + "OpenTelemetryTracingCollector requires `opentelemetry-api`. " + "Install with `uv add opentelemetry-api`.", + ) from exc + + self._trace_api = trace_api + self._tracer_name = tracer_name + self._span_name_template = span_name_template + self._span_enricher = span_enricher + if tracer_provider is None: + self._tracer = trace_api.get_tracer(tracer_name) + else: + self._tracer = trace_api.get_tracer( + tracer_name, + tracer_provider=tracer_provider, + ) + + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + parsed = urlsplit(metadata.url) + path = parsed.path or "/" + span_name = self._span_name_template.format(method=metadata.method, path=path) + + span_kind = getattr(self._trace_api, "SpanKind", None) + if span_kind is None: + span = self._tracer.start_span(span_name) + else: + span = self._tracer.start_span(span_name, kind=span_kind.CLIENT) + + span.set_attribute("http.method", metadata.method) + span.set_attribute("http.url", metadata.url) + span.set_attribute("http.target", path) + if parsed.hostname is not None: + span.set_attribute("server.address", parsed.hostname) + if parsed.port is not None: + span.set_attribute("server.port", parsed.port) + + if self._span_enricher is not None: + try: + self._span_enricher(span, metadata) + except Exception as exc: + span.record_exception(exc) + status_cls = getattr(self._trace_api, "Status", None) + status_code_cls = getattr(self._trace_api, "StatusCode", None) + if status_cls is not None and status_code_cls is not None: + span.set_status(status_cls(status_code_cls.ERROR, str(exc))) + span.end() + raise + + self._push_span(span) + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + span = self._peek_span() + if span is None: + return + + span.add_event( + "botx.retry", + attributes={ + "retry.attempt": retry_event.attempt, + "retry.max_attempts": retry_event.max_attempts, + "retry.sleep_seconds": retry_event.sleep_seconds, + "retry.reason": retry_event.reason, + "http.method": metadata.method, + }, + ) + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + span = self._pop_span() + if span is None: + return + + span.set_attribute("botx.duration_ms", result.duration_ms) + if result.status_code is not None: + span.set_attribute("http.status_code", result.status_code) + + status_cls = getattr(self._trace_api, "Status", None) + status_code_cls = getattr(self._trace_api, "StatusCode", None) + + if result.error is not None: + span.record_exception(result.error) + if status_cls is not None and status_code_cls is not None: + span.set_status( + status_cls(status_code_cls.ERROR, str(result.error)), + ) + elif status_cls is not None and status_code_cls is not None: + span.set_status(status_cls(status_code_cls.OK)) + + span.end() + + def _push_span(self, span: Any) -> None: + span_stack = self._span_stack_var.get() + self._span_stack_var.set((*span_stack, span)) + + def _peek_span(self) -> Any | None: + span_stack = self._span_stack_var.get() + if not span_stack: + return None + return span_stack[-1] + + def _pop_span(self) -> Any | None: + span_stack = self._span_stack_var.get() + if not span_stack: + return None + span = span_stack[-1] + self._span_stack_var.set(span_stack[:-1]) + return span diff --git a/pybotx/client/smartapps_api/upload_file.py b/pybotx/client/smartapps_api/upload_file.py index 76cbb5d9..a9dcada2 100644 --- a/pybotx/client/smartapps_api/upload_file.py +++ b/pybotx/client/smartapps_api/upload_file.py @@ -22,6 +22,7 @@ def to_domain(self) -> str: class UploadFileMethod(AuthorizedBotXMethod): + operation_name = "SmartAppsUploadFileMethod" status_handlers = { **AuthorizedBotXMethod.status_handlers, 400: response_exception_thrower(FileTypeNotAllowed), diff --git a/pybotx/client/users_api/search_user_by_email.py b/pybotx/client/users_api/search_user_by_email.py index ad11a589..106df332 100644 --- a/pybotx/client/users_api/search_user_by_email.py +++ b/pybotx/client/users_api/search_user_by_email.py @@ -8,16 +8,33 @@ BotXAPISearchUserByEmailsResponsePayload, BotXAPISearchUserResponsePayload, ) +from pybotx.client.users_api.trusted_search import validate_trusted_search_options from pybotx.logger import logger +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailRequestPayload(UnverifiedPayloadBaseModel): email: str + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod - def from_domain(cls, email: str) -> "BotXAPISearchUserByEmailRequestPayload": - return cls(email=email) + def from_domain( + cls, + email: str, + trusts_search: bool = False, + partial_response: bool = False, + ) -> "BotXAPISearchUserByEmailRequestPayload": + validate_trusted_search_options( + trusts_search=trusts_search, + partial_response=partial_response, + ) + return cls( + email=email, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailMethod(AuthorizedBotXMethod): @@ -67,7 +84,14 @@ async def execute( path = "/api/v3/botx/users/by_email" email = payload.email - request_json = {"emails": [email]} + request_json = { + "emails": [email], + "trusts_search": payload.trusts_search, + "partial_response": payload.partial_response, + } + request_json = { + key: value for key, value in request_json.items() if value is not Undefined + } response = await self._botx_method_call( "POST", diff --git a/pybotx/client/users_api/search_user_by_emails.py b/pybotx/client/users_api/search_user_by_emails.py index 8981c355..01137da6 100644 --- a/pybotx/client/users_api/search_user_by_emails.py +++ b/pybotx/client/users_api/search_user_by_emails.py @@ -3,18 +3,32 @@ from pybotx.client.users_api.user_from_search import ( BotXAPISearchUserByEmailsResponsePayload, ) +from pybotx.client.users_api.trusted_search import validate_trusted_search_options +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailsRequestPayload(UnverifiedPayloadBaseModel): emails: list[str] + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod def from_domain( cls, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> "BotXAPISearchUserByEmailsRequestPayload": - return cls(emails=emails) + validate_trusted_search_options( + trusts_search=trusts_search, + partial_response=partial_response, + ) + return cls( + emails=emails, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailsMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/search_user_by_huid.py b/pybotx/client/users_api/search_user_by_huid.py index 9fbf8b34..299def2a 100644 --- a/pybotx/client/users_api/search_user_by_huid.py +++ b/pybotx/client/users_api/search_user_by_huid.py @@ -4,15 +4,32 @@ from pybotx.client.botx_method import response_exception_thrower from pybotx.client.exceptions.users import UserNotFoundError from pybotx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from pybotx.client.users_api.trusted_search import validate_trusted_search_options +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByHUIDRequestPayload(UnverifiedPayloadBaseModel): user_huid: UUID + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod - def from_domain(cls, huid: UUID) -> "BotXAPISearchUserByHUIDRequestPayload": - return cls(user_huid=huid) + def from_domain( + cls, + huid: UUID, + trusts_search: bool = False, + partial_response: bool = False, + ) -> "BotXAPISearchUserByHUIDRequestPayload": + validate_trusted_search_options( + trusts_search=trusts_search, + partial_response=partial_response, + ) + return cls( + user_huid=huid, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByHUIDMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/search_user_by_login.py b/pybotx/client/users_api/search_user_by_login.py index 0b74ba1e..c552c25b 100644 --- a/pybotx/client/users_api/search_user_by_login.py +++ b/pybotx/client/users_api/search_user_by_login.py @@ -2,20 +2,35 @@ from pybotx.client.botx_method import response_exception_thrower from pybotx.client.exceptions.users import UserNotFoundError from pybotx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from pybotx.client.users_api.trusted_search import validate_trusted_search_options +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByLoginRequestPayload(UnverifiedPayloadBaseModel): ad_login: str ad_domain: str + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod def from_domain( cls, ad_login: str, ad_domain: str, + trusts_search: bool = False, + partial_response: bool = False, ) -> "BotXAPISearchUserByLoginRequestPayload": - return cls(ad_login=ad_login, ad_domain=ad_domain) + validate_trusted_search_options( + trusts_search=trusts_search, + partial_response=partial_response, + ) + return cls( + ad_login=ad_login, + ad_domain=ad_domain, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByLoginMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/search_user_by_other_id.py b/pybotx/client/users_api/search_user_by_other_id.py index 0e511c53..0a868f0b 100644 --- a/pybotx/client/users_api/search_user_by_other_id.py +++ b/pybotx/client/users_api/search_user_by_other_id.py @@ -2,15 +2,32 @@ from pybotx.client.botx_method import response_exception_thrower from pybotx.client.exceptions.users import UserNotFoundError from pybotx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from pybotx.client.users_api.trusted_search import validate_trusted_search_options +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByOtherIdRequestPayload(UnverifiedPayloadBaseModel): other_id: str + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod - def from_domain(cls, other_id: str) -> "BotXAPISearchUserByOtherIdRequestPayload": - return cls(other_id=other_id) + def from_domain( + cls, + other_id: str, + trusts_search: bool = False, + partial_response: bool = False, + ) -> "BotXAPISearchUserByOtherIdRequestPayload": + validate_trusted_search_options( + trusts_search=trusts_search, + partial_response=partial_response, + ) + return cls( + other_id=other_id, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByOtherIdMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/trusted_search.py b/pybotx/client/users_api/trusted_search.py new file mode 100644 index 00000000..525dd14a --- /dev/null +++ b/pybotx/client/users_api/trusted_search.py @@ -0,0 +1,7 @@ +def validate_trusted_search_options( + *, + trusts_search: bool, + partial_response: bool, +) -> None: + if partial_response and not trusts_search: + raise ValueError("`partial_response=True` requires `trusts_search=True`") diff --git a/pybotx/client/users_api/update_user_profile.py b/pybotx/client/users_api/update_user_profile.py index 5f638f57..a257d42f 100644 --- a/pybotx/client/users_api/update_user_profile.py +++ b/pybotx/client/users_api/update_user_profile.py @@ -3,7 +3,11 @@ from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower -from pybotx.client.exceptions.users import InvalidProfileDataError, UserNotFoundError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserNotFoundError, + UserProfileUpdateUnavailableError, +) from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel from pybotx.models.attachments import ( @@ -67,6 +71,7 @@ class UpdateUsersProfileMethod(AuthorizedBotXMethod): **AuthorizedBotXMethod.status_handlers, 400: response_exception_thrower(InvalidProfileDataError), 404: response_exception_thrower(UserNotFoundError), + 503: response_exception_thrower(UserProfileUpdateUnavailableError), } async def execute( diff --git a/pybotx/client/users_api/user_from_csv.py b/pybotx/client/users_api/user_from_csv.py index 275b9645..88b9bb38 100644 --- a/pybotx/client/users_api/user_from_csv.py +++ b/pybotx/client/users_api/user_from_csv.py @@ -23,17 +23,17 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): company: str | None = Field(alias="Company") department: str | None = Field(alias="Department") position: str | None = Field(alias="Position") - avatar: str | None = Field(alias="Avatar") - avatar_preview: str | None = Field(alias="Avatar preview") - office: str | None = Field(alias="Office") + avatar: str | None = Field(default=None, alias="Avatar") + avatar_preview: str | None = Field(default=None, alias="Avatar preview") + office: str | None = Field(default=None, alias="Office") manager: str | None = Field(alias="Manager") manager_huid: UUID | None = Field(alias="Manager HUID") - description: str | None = Field(alias="Description") - phone: str | None = Field(alias="Phone") - other_phone: str | None = Field(alias="Other phone") - ip_phone: str | None = Field(alias="IP phone") - other_ip_phone: str | None = Field(alias="Other IP phone") - personnel_number: str | None = Field(alias="Personnel number") + description: str | None = Field(default=None, alias="Description") + phone: str | None = Field(default=None, alias="Phone") + other_phone: str | None = Field(default=None, alias="Other phone") + ip_phone: str | None = Field(default=None, alias="IP phone") + other_ip_phone: str | None = Field(default=None, alias="Other IP phone") + personnel_number: str | None = Field(default=None, alias="Personnel number") @field_validator( "email", diff --git a/pybotx/client/users_api/user_from_search.py b/pybotx/client/users_api/user_from_search.py index 92d257c5..2c1c8ffb 100644 --- a/pybotx/client/users_api/user_from_search.py +++ b/pybotx/client/users_api/user_from_search.py @@ -2,16 +2,19 @@ from typing import Literal from uuid import UUID +from pydantic import Field, field_validator + from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import APIUserKinds, convert_user_kind_to_domain from pybotx.models.users import UserFromSearch -from pydantic import Field class BotXAPISearchUserResult(VerifiedPayloadBaseModel): user_huid: UUID ad_login: str | None = None ad_domain: str | None = None + avatar: str | None = None + avatar_preview: str | None = None name: str company: str | None = None company_position: str | None = None @@ -32,6 +35,14 @@ class BotXAPISearchUserResult(VerifiedPayloadBaseModel): created_at: datetime | None = None updated_at: datetime | None = None + @field_validator("ip_phone", "other_ip_phone", "other_phone", mode="before") + @classmethod + def convert_phone_to_string(cls, value: str | int | None) -> str | None: + if value is None: + return None + + return str(value) + class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] @@ -42,6 +53,8 @@ def to_domain(self) -> UserFromSearch: huid=self.result.user_huid, ad_login=self.result.ad_login, ad_domain=self.result.ad_domain, + avatar=self.result.avatar, + avatar_preview=self.result.avatar_preview, username=self.result.name, company=self.result.company, company_position=self.result.company_position, @@ -74,6 +87,8 @@ def to_domain(self) -> list[UserFromSearch]: huid=user.user_huid, ad_login=user.ad_login, ad_domain=user.ad_domain, + avatar=user.avatar, + avatar_preview=user.avatar_preview, username=user.name, company=user.company, company_position=user.company_position, diff --git a/pybotx/client/voex_api/__init__.py b/pybotx/client/voex_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pybotx/client/voex_api/exceptions.py b/pybotx/client/voex_api/exceptions.py new file mode 100644 index 00000000..08363e08 --- /dev/null +++ b/pybotx/client/voex_api/exceptions.py @@ -0,0 +1,9 @@ +from pybotx.client.exceptions.base import BaseClientError + + +class ConferenceNotFoundError(BaseClientError): + """Conference with specified call_id not found.""" + + +class CallNotFoundError(BaseClientError): + """Call with specified call_id not found.""" diff --git a/pybotx/client/voex_api/get_call.py b/pybotx/client/voex_api/get_call.py new file mode 100644 index 00000000..bd5be924 --- /dev/null +++ b/pybotx/client/voex_api/get_call.py @@ -0,0 +1,59 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.voex_api.exceptions import CallNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.call import Call + + +class BotXAPIGetCallRequestPayload(UnverifiedPayloadBaseModel): + call_id: UUID + + @classmethod + def from_domain( + cls, + call_id: UUID, + ) -> "BotXAPIGetCallRequestPayload": + return cls(call_id=call_id) + + +class BotXAPIGetCallResult(VerifiedPayloadBaseModel): + call_id: UUID + members: list[UUID] + + +class BotXAPIGetCallResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetCallResult + + def to_domain(self) -> Call: + return Call( + id=self.result.call_id, + members=self.result.members, + ) + + +class GetCallMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(CallNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetCallRequestPayload, + ) -> BotXAPIGetCallResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = f"/api/v3/botx/voex/calls/{jsonable_dict['call_id']}" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetCallResponsePayload, + response, + ) diff --git a/pybotx/client/voex_api/get_conference.py b/pybotx/client/voex_api/get_conference.py new file mode 100644 index 00000000..75023c22 --- /dev/null +++ b/pybotx/client/voex_api/get_conference.py @@ -0,0 +1,63 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.voex_api.exceptions import ConferenceNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.conference import Conference + + +class BotXAPIGetConferenceRequestPayload(UnverifiedPayloadBaseModel): + call_id: UUID + + @classmethod + def from_domain( + cls, + call_id: UUID, + ) -> "BotXAPIGetConferenceRequestPayload": + return cls(call_id=call_id) + + +class BotXAPIGetConferenceResult(VerifiedPayloadBaseModel): + call_id: UUID + name: str + link: str | None + members: list[UUID] + + +class BotXAPIGetConferenceResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetConferenceResult + + def to_domain(self) -> Conference: + return Conference( + id=self.result.call_id, + name=self.result.name, + link=self.result.link, + members=self.result.members, + ) + + +class GetConferenceMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(ConferenceNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetConferenceRequestPayload, + ) -> BotXAPIGetConferenceResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = f"/api/v3/botx/voex/conferences/{jsonable_dict['call_id']}" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetConferenceResponsePayload, + response, + ) diff --git a/pybotx/constants.py b/pybotx/constants.py index 6f499cf0..81edf4ee 100644 --- a/pybotx/constants.py +++ b/pybotx/constants.py @@ -8,3 +8,20 @@ MAX_NOTIFICATION_BODY_LENGTH: Final = 4096 MAX_FILE_LEN_IN_LOGS: Final = 64 BOTX_DEFAULT_TIMEOUT: Final = 60 + +BOTX_HTTP_CONNECT_TIMEOUT_SECONDS: Final = 3.0 +BOTX_HTTP_READ_TIMEOUT_SECONDS: Final = 30.0 +BOTX_HTTP_WRITE_TIMEOUT_SECONDS: Final = 10.0 +BOTX_HTTP_POOL_TIMEOUT_SECONDS: Final = 1.0 + +BOTX_HTTP_MAX_CONNECTIONS: Final = 500 +BOTX_HTTP_MAX_KEEPALIVE_CONNECTIONS: Final = 100 +BOTX_HTTP_KEEPALIVE_EXPIRY_SECONDS: Final = 30.0 + +BOTX_HTTP_RETRY_ATTEMPTS: Final = 4 +BOTX_HTTP_RETRY_INITIAL_DELAY_SECONDS: Final = 0.2 +BOTX_HTTP_RETRY_MAX_DELAY_SECONDS: Final = 5.0 +BOTX_HTTP_RETRY_JITTER_SECONDS: Final = 0.2 + +BOTX_COMMAND_PROCESSING_MAX_CONCURRENCY: Final = 100 +BOTX_COMMAND_PROCESSING_MAX_QUEUE_SIZE: Final = 1000 diff --git a/pybotx/integrations/__init__.py b/pybotx/integrations/__init__.py new file mode 100644 index 00000000..e295063c --- /dev/null +++ b/pybotx/integrations/__init__.py @@ -0,0 +1,11 @@ +from pybotx.integrations.fastapi import ( + FastAPIBotAppConfig, + create_fastapi_bot_app, + setup_fastapi_bot, +) + +__all__ = [ + "FastAPIBotAppConfig", + "create_fastapi_bot_app", + "setup_fastapi_bot", +] diff --git a/pybotx/integrations/fastapi.py b/pybotx/integrations/fastapi.py new file mode 100644 index 00000000..51b296ab --- /dev/null +++ b/pybotx/integrations/fastapi.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from http import HTTPStatus +import importlib +from typing import TYPE_CHECKING, Any, cast + +from pybotx.bot.api.responses.bot_disabled import build_bot_disabled_response +from pybotx.bot.api.responses.command_accepted import build_command_accepted_response +from pybotx.bot.api.responses.unverified_request import ( + build_unverified_request_response, +) +from pybotx.bot.bot import Bot +from pybotx.bot.exceptions import UnknownBotAccountError, UnverifiedRequestError +from pybotx.bot.healthcheck import HealthcheckService, setup_healthcheck +from pybotx.logger import logger + +if TYPE_CHECKING: + from fastapi import FastAPI, Request + + +@dataclass(frozen=True, slots=True) +class FastAPIBotAppConfig: + command_path: str = "/command" + status_path: str = "/status" + callback_path: str = "/notification/callback" + smartapp_request_path: str | None = None + metrics_path: str | None = None + healthcheck_prefix: str | None = "/health" + verify_request: bool = True + verify_callback_request: bool = False + trusted_issuers: frozenset[str] | None = None + + def __post_init__(self) -> None: + route_paths = { + "command_path": self.command_path, + "status_path": self.status_path, + "callback_path": self.callback_path, + "smartapp_request_path": self.smartapp_request_path, + "metrics_path": self.metrics_path, + "healthcheck_prefix": self.healthcheck_prefix, + } + + seen_paths: set[str] = set() + for field_name, path in route_paths.items(): + if path is None: + continue + if not path.startswith("/"): + raise ValueError(f"`{field_name}` should start with '/'") + if path in seen_paths: + raise ValueError(f"Duplicate FastAPI route path `{path}`") + seen_paths.add(path) + + +def create_fastapi_bot_app( + *, + bot: Bot, + title: str = "pybotx", + openapi_url: str | None = "/openapi.json", + docs_url: str | None = "/docs", + redoc_url: str | None = "/redoc", + config: FastAPIBotAppConfig | None = None, + metrics_registry: Any | None = None, + healthcheck: HealthcheckService | None = None, + app_state: Mapping[str, Any] | None = None, +) -> "FastAPI": + fastapi_module, _, _, _ = _import_fastapi_runtime() + fastapi_cls = getattr(fastapi_module, "FastAPI") + + app = fastapi_cls( + title=title, + openapi_url=openapi_url, + docs_url=docs_url, + redoc_url=redoc_url, + ) + setup_fastapi_bot( + app, + bot=bot, + config=config, + metrics_registry=metrics_registry, + healthcheck=healthcheck, + app_state=app_state, + ) + return cast("FastAPI", app) + + +def setup_fastapi_bot( + app: "FastAPI", + *, + bot: Bot, + config: FastAPIBotAppConfig | None = None, + metrics_registry: Any | None = None, + healthcheck: HealthcheckService | None = None, + app_state: Mapping[str, Any] | None = None, +) -> HealthcheckService | None: + config = config or FastAPIBotAppConfig() + _, request_cls, response_cls, json_response_cls = _import_fastapi_runtime() + globals()["Request"] = request_cls + + if config.metrics_path is not None and metrics_registry is None: + raise ValueError( + "`metrics_registry` should be provided when `metrics_path` is configured", + ) + if config.healthcheck_prefix is None and healthcheck is not None: + raise ValueError( + "`healthcheck` can't be passed when `healthcheck_prefix` is disabled", + ) + reserved_state_keys = {"bot", "healthcheck"} + if app_state is not None and reserved_state_keys.intersection(app_state): + reserved_keys = ", ".join(sorted(reserved_state_keys.intersection(app_state))) + raise ValueError( + f"`app_state` can't override reserved state keys: {reserved_keys}", + ) + + app.state.bot = bot + for state_key, state_value in (app_state or {}).items(): + setattr(app.state, state_key, state_value) + + app.add_event_handler("startup", bot.startup) + app.add_event_handler("shutdown", bot.shutdown) + + if config.healthcheck_prefix is not None: + healthcheck = setup_healthcheck( + app, + healthcheck=healthcheck, + prefix=config.healthcheck_prefix, + ) + app.state.healthcheck = healthcheck + + @app.post(config.command_path) + async def command_handler(request: Request) -> Any: + try: + bot.async_execute_raw_bot_command( + await request.json(), + verify_request=config.verify_request, + request_headers=request.headers, + trusted_issuers=_trusted_issuers(config), + ) + except ValueError as exc: + return _build_disabled_response( + str(exc) or "Bot command validation error", + json_response_cls, + ) + except UnknownBotAccountError as exc: + error_label = f"No credentials for bot {exc.bot_id}" + logger.warning(error_label) + return _build_disabled_response(error_label, json_response_cls) + except UnverifiedRequestError as exc: + return _build_unverified_response(str(exc), json_response_cls) + + return json_response_cls( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + @app.get(config.status_path) + async def status_handler(request: Request) -> Any: + try: + status = await bot.raw_get_status( + dict(request.query_params), + verify_request=config.verify_request, + request_headers=request.headers, + trusted_issuers=_trusted_issuers(config), + ) + except ValueError as exc: + return _build_disabled_response( + str(exc) or "Status request validation error", + json_response_cls, + ) + except UnknownBotAccountError as exc: + error_label = f"No credentials for bot {exc.bot_id}" + logger.warning(error_label) + return _build_disabled_response(error_label, json_response_cls) + except UnverifiedRequestError as exc: + return _build_unverified_response(str(exc), json_response_cls) + + return json_response_cls(status, status_code=HTTPStatus.OK) + + @app.post(config.callback_path) + async def callback_handler(request: Request) -> Any: + try: + await bot.set_raw_botx_method_result( + await request.json(), + verify_request=config.verify_callback_request, + request_headers=request.headers, + trusted_issuers=_trusted_issuers(config), + ) + except UnverifiedRequestError as exc: + return _build_unverified_response(str(exc), json_response_cls) + + return json_response_cls( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + if config.smartapp_request_path is not None: + + @app.post(config.smartapp_request_path) + async def sync_smartapp_event_handler(request: Request) -> Any: + try: + response = await bot.sync_execute_raw_smartapp_event( + await request.json(), + verify_request=config.verify_request, + request_headers=request.headers, + trusted_issuers=_trusted_issuers(config), + ) + except ValueError as exc: + return _build_disabled_response( + str(exc) or "Sync smartapp event validation error", + json_response_cls, + ) + except UnknownBotAccountError as exc: + error_label = f"No credentials for bot {exc.bot_id}" + logger.warning(error_label) + return _build_disabled_response(error_label, json_response_cls) + except UnverifiedRequestError as exc: + return _build_unverified_response(str(exc), json_response_cls) + + return json_response_cls(response.jsonable_dict(), status_code=HTTPStatus.OK) + + if config.metrics_path is not None: + generate_latest, content_type = _build_metrics_runtime() + + @app.get(config.metrics_path) + async def metrics_handler() -> Any: + return response_cls( + content=generate_latest(metrics_registry), + media_type=content_type, + ) + + return healthcheck + + +def _build_disabled_response(error_label: str, json_response_cls: Any) -> Any: + logger.exception(error_label) + return json_response_cls( + build_bot_disabled_response(error_label), + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + ) + + +def _build_unverified_response(error_label: str, json_response_cls: Any) -> Any: + logger.warning(error_label) + return json_response_cls( + build_unverified_request_response(status_message=error_label), + status_code=HTTPStatus.UNAUTHORIZED, + ) + + +def _trusted_issuers(config: FastAPIBotAppConfig) -> set[str] | None: + if config.trusted_issuers is None: + return None + return set(config.trusted_issuers) + + +def _build_metrics_runtime() -> tuple[Any, str]: + try: + prometheus_client = importlib.import_module("prometheus_client") + except ModuleNotFoundError as exc: + raise RuntimeError( + "FastAPI metrics endpoint requires `prometheus-client`. " + "Install with `uv add prometheus-client`.", + ) from exc + + return ( + getattr(prometheus_client, "generate_latest"), + getattr(prometheus_client, "CONTENT_TYPE_LATEST"), + ) + + +def _import_fastapi_runtime() -> tuple[Any, Any, Any, Any]: + try: + fastapi_module = importlib.import_module("fastapi") + fastapi_responses_module = importlib.import_module("fastapi.responses") + except ModuleNotFoundError as exc: + raise RuntimeError( + "FastAPI integration requires `fastapi`. " + "Install with `uv add fastapi`.", + ) from exc + + return ( + fastapi_module, + getattr(fastapi_module, "Request"), + getattr(fastapi_module, "Response"), + getattr(fastapi_responses_module, "JSONResponse"), + ) diff --git a/pybotx/logger.py b/pybotx/logger.py index ba7b419a..230266b3 100644 --- a/pybotx/logger.py +++ b/pybotx/logger.py @@ -1,9 +1,13 @@ import json +import os +import sys from copy import deepcopy +from contextvars import ContextVar from typing import TYPE_CHECKING, Any from loguru import logger as _logger +from pybotx.bot.contextvars import bot_id_var, chat_id_var, request_id_var, trace_id_var from pybotx.constants import MAX_FILE_LEN_IN_LOGS if TYPE_CHECKING: # To avoid circular import @@ -51,7 +55,40 @@ def log_incoming_request(request: dict[str, Any], *, message: str = "") -> None: def setup_logger() -> "Logger": - return _logger + logger = _logger.patch(_inject_correlation_fields) + if _is_env_enabled("PYBOTX_CONFIGURE_LOGGER", default=True): + logger.remove() + logger.add( + sys.stderr, + level=os.getenv("PYBOTX_LOG_LEVEL", "DEBUG"), + serialize=_is_env_enabled("PYBOTX_LOG_STRUCTURED", default=True), + backtrace=False, + diagnose=False, + ) + return logger + + +def _inject_correlation_fields(record: Any) -> None: + extra = record["extra"] + extra.setdefault("trace_id", _get_optional_contextvar_value(trace_id_var)) + extra.setdefault("request_id", _get_optional_contextvar_value(request_id_var)) + extra.setdefault("chat_id", _get_optional_contextvar_value(chat_id_var)) + extra.setdefault("bot_id", _get_optional_contextvar_value(bot_id_var)) + + +def _get_optional_contextvar_value(context_var: ContextVar[Any]) -> str | None: + try: + value = context_var.get() + except LookupError: + return None + return str(value) + + +def _is_env_enabled(name: str, *, default: bool) -> bool: + raw_value = os.getenv(name) + if raw_value is None: + return default + return raw_value.lower() not in {"0", "false", "no", "off"} logger = setup_logger() diff --git a/pybotx/models/attachments.py b/pybotx/models/attachments.py index 9882eca3..14cccce4 100644 --- a/pybotx/models/attachments.py +++ b/pybotx/models/attachments.py @@ -501,6 +501,12 @@ def encode_rfc2397(content: bytes, mimetype: str) -> str: return f"data:{mimetype};base64,{b64_content}" +def get_mimetype_by_filename(filename: str) -> str: + extension = filename.rsplit(".", 1)[-1].lower() + + return EXTENSIONS_TO_MIMETYPES.get(extension, DEFAULT_MIMETYPE) + + class BotXAPIAttachment(UnverifiedPayloadBaseModel): file_name: str data: str @@ -512,10 +518,7 @@ def from_file_attachment( ) -> "BotXAPIAttachment": assert attachment.content is not None - mimetype = EXTENSIONS_TO_MIMETYPES.get( - attachment.filename.split(".")[-1], - DEFAULT_MIMETYPE, - ) + mimetype = get_mimetype_by_filename(attachment.filename) return cls( file_name=attachment.filename, diff --git a/pybotx/models/call.py b/pybotx/models/call.py new file mode 100644 index 00000000..3dd45e11 --- /dev/null +++ b/pybotx/models/call.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(slots=True) +class Call: + id: UUID + members: list[UUID] diff --git a/pybotx/models/conference.py b/pybotx/models/conference.py new file mode 100644 index 00000000..3a373fc4 --- /dev/null +++ b/pybotx/models/conference.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(slots=True) +class Conference: + id: UUID + name: str + link: str | None + members: list[UUID] diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index b458b56d..6da4f895 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -60,12 +60,14 @@ class ChatTypes(AutoName): GROUP_CHAT: Group chat. CHANNEL: Public channel. THREAD: Thread in a chat. + VOEX_CALL: Incoming VoEx call. It cannot be used to create a chat. """ PERSONAL_CHAT = auto() GROUP_CHAT = auto() CHANNEL = auto() THREAD = auto() + VOEX_CALL = auto() class SyncSourceTypes(AutoName): @@ -366,7 +368,7 @@ def convert_chat_type_to_domain( APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, APIChatTypes.CHANNEL: ChatTypes.CHANNEL, APIChatTypes.THREAD: ChatTypes.THREAD, - APIChatTypes.VOEX_CALL: ChatTypes.GROUP_CHAT, + APIChatTypes.VOEX_CALL: ChatTypes.VOEX_CALL, } converted_type: IncomingChatTypes | None diff --git a/pybotx/models/message/markup.py b/pybotx/models/message/markup.py index 587049dd..cb06c0b2 100644 --- a/pybotx/models/message/markup.py +++ b/pybotx/models/message/markup.py @@ -178,7 +178,7 @@ class BotXAPIButtonOptions(UnverifiedPayloadBaseModel): class BotXAPIButton(UnverifiedPayloadBaseModel): - command: str + command: Missing[str] label: str data: dict[str, Any] opts: BotXAPIButtonOptions diff --git a/pybotx/models/message/mentions.py b/pybotx/models/message/mentions.py index d11d5914..10b63194 100644 --- a/pybotx/models/message/mentions.py +++ b/pybotx/models/message/mentions.py @@ -242,6 +242,7 @@ def to_botx_embed_mention_format(self) -> str: class BotXAPIAllMention(UnverifiedPayloadBaseModel): mention_type: Literal[BotAPIMentionTypes.ALL] mention_id: UUID + mention_data: None = None def to_botx_embed_mention_format(self) -> str: return f"@{{mention:{self.mention_id}}}" diff --git a/pybotx/models/users.py b/pybotx/models/users.py index 79bb736e..b0ca61ca 100644 --- a/pybotx/models/users.py +++ b/pybotx/models/users.py @@ -13,6 +13,8 @@ class UserFromSearch: huid: User huid. ad_login: User AD login. ad_domain: User AD domain. + avatar: User avatar (link). + avatar_preview: User avatar preview (link). username: User name. company: User company. company_position: User company position. @@ -44,6 +46,8 @@ class UserFromSearch: emails: list[str] other_id: str | None user_kind: UserKinds + avatar: str | None = None + avatar_preview: str | None = None active: bool | None = None description: str | None = None ip_phone: str | None = None diff --git a/pybotx/presets.py b/pybotx/presets.py new file mode 100644 index 00000000..ae470e14 --- /dev/null +++ b/pybotx/presets.py @@ -0,0 +1,217 @@ +from collections.abc import Collection, Sequence +from dataclasses import dataclass +from typing import Any + +from pybotx.bot.ingress_observability import ( + BotIngressMetricsCollector, + PrometheusIngressMetricsCollector, + ReasonNormalizer as IngressReasonNormalizer, +) +from pybotx.client.http_config import ( + AllowedRequestPattern, + AnyOfBotXRetryRequestPolicy, + BotXOperation, + BotXRetryPolicy, + BotXRetryRequestPolicy, + BotXRetryStrategy, + KnownSafeBotXRetryRequestPolicy, + OperationName, + SafeBotXRetryRequestPolicy, +) +from pybotx.client.observability import ( + BotXRequestObserver, + OpenTelemetryTracingCollector, + PathNormalizer, + PrometheusMetricsCollector, + ReasonNormalizer as OutgoingReasonNormalizer, + SpanEnricher, +) +from pybotx.constants import ( + BOTX_HTTP_RETRY_ATTEMPTS, + BOTX_HTTP_RETRY_INITIAL_DELAY_SECONDS, + BOTX_HTTP_RETRY_JITTER_SECONDS, + BOTX_HTTP_RETRY_MAX_DELAY_SECONDS, +) + + +def _normalize_operation_names( + operation_names: Collection[OperationName] | None, +) -> tuple[str, ...] | None: + if operation_names is None: + return None + + return tuple( + operation_name.value + if isinstance(operation_name, BotXOperation) + else operation_name + for operation_name in operation_names + ) + + +@dataclass(frozen=True, slots=True) +class BotRetryPreset: + retry_policy: BotXRetryPolicy + retry_request_policy: BotXRetryRequestPolicy + retry_strategy: BotXRetryStrategy | None = None + + def as_bot_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "retry_policy": self.retry_policy, + "retry_request_policy": self.retry_request_policy, + } + if self.retry_strategy is not None: + kwargs["retry_strategy"] = self.retry_strategy + return kwargs + + +@dataclass(frozen=True, slots=True) +class BotObservabilityPreset: + metrics_collector: BotXRequestObserver | None = None + tracing_collector: BotXRequestObserver | None = None + ingress_metrics_collector: BotIngressMetricsCollector | None = None + + def as_bot_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + if self.metrics_collector is not None: + kwargs["metrics_collector"] = self.metrics_collector + if self.tracing_collector is not None: + kwargs["tracing_collector"] = self.tracing_collector + if self.ingress_metrics_collector is not None: + kwargs["ingress_metrics_collector"] = self.ingress_metrics_collector + return kwargs + + +@dataclass(frozen=True, slots=True) +class BotProductionPreset: + retry: BotRetryPreset | None = None + observability: BotObservabilityPreset | None = None + + def as_bot_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + if self.retry is not None: + kwargs.update(self.retry.as_bot_kwargs()) + if self.observability is not None: + kwargs.update(self.observability.as_bot_kwargs()) + return kwargs + + +def build_production_retry_preset( + *, + max_attempts: int = BOTX_HTTP_RETRY_ATTEMPTS, + initial_delay_seconds: float = BOTX_HTTP_RETRY_INITIAL_DELAY_SECONDS, + max_delay_seconds: float = BOTX_HTTP_RETRY_MAX_DELAY_SECONDS, + jitter_seconds: float = BOTX_HTTP_RETRY_JITTER_SECONDS, + retryable_status_codes: Collection[int] | None = None, + retry_request_policy: BotXRetryRequestPolicy | None = None, + retry_strategy: BotXRetryStrategy | None = None, + extra_safe_requests: Collection[AllowedRequestPattern] | None = None, + extra_safe_operations: Collection[OperationName] | None = None, +) -> BotRetryPreset: + if retry_request_policy is not None and ( + extra_safe_requests is not None or extra_safe_operations is not None + ): + raise ValueError( + "`extra_safe_requests` and `extra_safe_operations` can't be passed " + "with custom `retry_request_policy`", + ) + + if retry_request_policy is None: + retry_request_policy = AnyOfBotXRetryRequestPolicy( + policies=( + SafeBotXRetryRequestPolicy( + extra_safe_requests=extra_safe_requests, + extra_safe_operation_names=_normalize_operation_names( + extra_safe_operations, + ), + ), + KnownSafeBotXRetryRequestPolicy(), + ), + ) + + if retryable_status_codes is None: + retry_policy = BotXRetryPolicy( + max_attempts=max_attempts, + initial_delay_seconds=initial_delay_seconds, + max_delay_seconds=max_delay_seconds, + jitter_seconds=jitter_seconds, + ) + else: + retry_policy = BotXRetryPolicy( + max_attempts=max_attempts, + initial_delay_seconds=initial_delay_seconds, + max_delay_seconds=max_delay_seconds, + jitter_seconds=jitter_seconds, + retryable_status_codes=frozenset(retryable_status_codes), + ) + + return BotRetryPreset( + retry_policy=retry_policy, + retry_request_policy=retry_request_policy, + retry_strategy=retry_strategy, + ) + + +def build_production_observability_preset( + *, + enable_prometheus_metrics: bool = True, + enable_open_telemetry_tracing: bool = True, + registry: Any | None = None, + botx_metric_prefix: str = "pybotx_botx", + ingress_metric_prefix: str = "pybotx_ingress", + botx_latency_buckets: Sequence[float] | None = None, + ingress_latency_buckets: Sequence[float] | None = None, + path_normalizer: PathNormalizer | None = None, + retry_reason_normalizer: OutgoingReasonNormalizer | None = None, + ingress_reason_normalizer: IngressReasonNormalizer | None = None, + tracer_name: str = "pybotx.botx", + tracer_provider: Any | None = None, + span_name_template: str = "{method} {path}", + span_enricher: SpanEnricher | None = None, +) -> BotObservabilityPreset: + if not enable_prometheus_metrics and not enable_open_telemetry_tracing: + raise ValueError( + "At least one observability backend should be enabled", + ) + + metrics_collector: BotXRequestObserver | None = None + ingress_metrics_collector: BotIngressMetricsCollector | None = None + if enable_prometheus_metrics: + metrics_collector = PrometheusMetricsCollector( + registry=registry, + metric_prefix=botx_metric_prefix, + latency_buckets=botx_latency_buckets, + path_normalizer=path_normalizer, + reason_normalizer=retry_reason_normalizer, + ) + ingress_metrics_collector = PrometheusIngressMetricsCollector( + registry=registry, + metric_prefix=ingress_metric_prefix, + latency_buckets=ingress_latency_buckets, + reason_normalizer=ingress_reason_normalizer, + ) + + tracing_collector: BotXRequestObserver | None = None + if enable_open_telemetry_tracing: + tracing_collector = OpenTelemetryTracingCollector( + tracer_name=tracer_name, + tracer_provider=tracer_provider, + span_name_template=span_name_template, + span_enricher=span_enricher, + ) + + return BotObservabilityPreset( + metrics_collector=metrics_collector, + tracing_collector=tracing_collector, + ingress_metrics_collector=ingress_metrics_collector, + ) + + +def build_production_bot_preset( + *, + retry_preset: BotRetryPreset | None = None, + observability_preset: BotObservabilityPreset | None = None, +) -> BotProductionPreset: + return BotProductionPreset( + retry=retry_preset or build_production_retry_preset(), + observability=observability_preset or build_production_observability_preset(), + ) diff --git a/pybotx/scaffold/__init__.py b/pybotx/scaffold/__init__.py new file mode 100644 index 00000000..13545bf0 --- /dev/null +++ b/pybotx/scaffold/__init__.py @@ -0,0 +1,75 @@ +from pybotx.scaffold.command import ( + CreateCommandOptions, + CreatedCommandArtifacts, + create_command_in_project, +) +from pybotx.scaffold.fsm_flow import ( + CreateFSMFlowOptions, + CreatedFSMFlowArtifacts, + create_fsm_flow_in_project, +) +from pybotx.scaffold.manifest import ( + PROJECT_MANIFEST_FILENAME, + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.port import ( + CreatePortOptions, + CreatedPortArtifacts, + create_port_in_project, +) +from pybotx.scaffold.project import ( + CreateBotProjectOptions, + CreatedBotProject, + create_bot_project, +) +from pybotx.scaffold.repository import ( + CreateRepositoryOptions, + CreatedRepositoryArtifacts, + create_repository_in_project, +) +from pybotx.scaffold.service import ( + CreateServiceOptions, + CreatedServiceArtifacts, + create_service_in_project, +) +from pybotx.scaffold.widget import ( + CreateWidgetOptions, + CreatedWidgetArtifacts, + create_widget_in_project, +) +from pybotx.scaffold.widget_flow import ( + CreateWidgetFlowOptions, + CreatedWidgetFlowArtifacts, + create_widget_flow_in_project, +) + +__all__ = [ + "CreateCommandOptions", + "CreateFSMFlowOptions", + "CreatePortOptions", + "CreateWidgetOptions", + "CreateWidgetFlowOptions", + "CreateRepositoryOptions", + "CreateServiceOptions", + "CreateBotProjectOptions", + "CreatedCommandArtifacts", + "CreatedFSMFlowArtifacts", + "CreatedPortArtifacts", + "CreatedRepositoryArtifacts", + "CreatedServiceArtifacts", + "CreatedWidgetArtifacts", + "CreatedWidgetFlowArtifacts", + "CreatedBotProject", + "PROJECT_MANIFEST_FILENAME", + "ScaffoldProjectManifest", + "create_command_in_project", + "create_fsm_flow_in_project", + "create_bot_project", + "create_port_in_project", + "create_repository_in_project", + "create_service_in_project", + "create_widget_in_project", + "create_widget_flow_in_project", + "find_scaffold_project_root", +] diff --git a/pybotx/scaffold/_shared.py b/pybotx/scaffold/_shared.py new file mode 100644 index 00000000..dc477855 --- /dev/null +++ b/pybotx/scaffold/_shared.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import keyword +from pathlib import Path +import re +from string import Template +from typing import Sequence + + +_NON_ALNUM_RE = re.compile(r"[^0-9a-zA-Z]+") + + +def normalize_scaffold_name(raw_name: str, *, kind: str) -> str: + normalized_name = _NON_ALNUM_RE.sub("-", raw_name.strip().lstrip("/")) + normalized_name = normalized_name.strip("-").lower() + if not normalized_name: + raise ValueError(f"{kind} should not be empty") + + module_name = slug_to_module_name(normalized_name) + if module_name[0].isdigit(): + raise ValueError(f"{kind} should not start with a digit") + if not module_name.isidentifier(): + raise ValueError(f"Invalid {kind.lower()} `{raw_name}`") + if keyword.iskeyword(module_name): + raise ValueError(f"{kind} `{raw_name}` resolves to a Python keyword") + + return normalized_name + + +def slug_to_module_name(normalized_name: str) -> str: + return normalized_name.replace("-", "_") + + +def module_name_to_pascal_case(module_name: str) -> str: + return "".join(part.capitalize() for part in module_name.split("_")) + + +def write_template(path: Path, template: Template, context: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(template.safe_substitute(context), encoding="utf-8") + + +def append_marked_lines( + path: Path, + *, + start_marker: str, + end_marker: str, + new_lines: Sequence[str], +) -> None: + original_content = path.read_text(encoding="utf-8") + trailing_newline = original_content.endswith("\n") + lines = original_content.splitlines() + + try: + start_index = lines.index(start_marker) + end_index = lines.index(end_marker) + except ValueError as exc: + raise RuntimeError( + f"Couldn't find scaffold markers in `{path}`", + ) from exc + + if start_index >= end_index: + raise RuntimeError(f"Invalid scaffold markers order in `{path}`") + + block_lines = lines[start_index + 1 : end_index] + for new_line in new_lines: + if new_line in block_lines: + raise FileExistsError(f"`{new_line}` already exists in `{path}`") + block_lines.append(new_line) + + updated_lines = lines[: start_index + 1] + block_lines + lines[end_index:] + path.write_text( + "\n".join(updated_lines) + ("\n" if trailing_newline else ""), + encoding="utf-8", + ) diff --git a/pybotx/scaffold/command.py b/pybotx/scaffold/command.py new file mode 100644 index 00000000..a055487b --- /dev/null +++ b/pybotx/scaffold/command.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + COMMAND_MODULE_TEMPLATE, + COMMAND_TEST_TEMPLATE, + SERVICE_MODULE_TEMPLATE, +) + +_COMMAND_IMPORTS_START = "# pybotx-scaffold command imports start" +_COMMAND_IMPORTS_END = "# pybotx-scaffold command imports end" +_COMMAND_COLLECTORS_START = " # pybotx-scaffold command collectors start" +_COMMAND_COLLECTORS_END = " # pybotx-scaffold command collectors end" +_SERVICE_IMPORTS_START = "# pybotx-scaffold service imports start" +_SERVICE_IMPORTS_END = "# pybotx-scaffold service imports end" +_SERVICE_PROVIDERS_START = " # pybotx-scaffold service providers start" +_SERVICE_PROVIDERS_END = " # pybotx-scaffold service providers end" + + +@dataclass(frozen=True, slots=True) +class CreateCommandOptions: + command_name: str + project_dir: Path | str = "." + description: str | None = None + response_text: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedCommandArtifacts: + project_root: Path + package_name: str + command_path: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _CommandSpec: + module_name: str + command_path: str + description: str + response_text: str + service_class_name: str + service_provider_name: str + collector_alias: str + handler_name: str + + +def create_command_in_project( + options: CreateCommandOptions, +) -> CreatedCommandArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + command_spec = _build_command_spec( + command_name=options.command_name, + description=options.description, + response_text=options.response_text, + ) + + command_module_path = project_root / manifest.commands_dir / ( + f"{command_spec.module_name}.py" + ) + service_module_path = project_root / manifest.services_dir / ( + f"{command_spec.module_name}.py" + ) + test_module_path = project_root / manifest.tests_dir / ( + f"test_{command_spec.module_name}.py" + ) + + for path in (command_module_path, service_module_path, test_module_path): + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + context = { + "command_literal": repr(command_spec.command_path), + "container_module": manifest.container_module, + "description_literal": repr(command_spec.description), + "handler_name": command_spec.handler_name, + "module_name": command_spec.module_name, + "package_name": manifest.package_name, + "response_text_literal": repr(command_spec.response_text), + "service_class_name": command_spec.service_class_name, + "service_provider_name": command_spec.service_provider_name, + } + + write_template(command_module_path, COMMAND_MODULE_TEMPLATE, context) + write_template(service_module_path, SERVICE_MODULE_TEMPLATE, context) + write_template(test_module_path, COMMAND_TEST_TEMPLATE, context) + + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_IMPORTS_START, + end_marker=_COMMAND_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.commands_module_prefix}.{command_spec.module_name} " + f"import collector as {command_spec.collector_alias}" + ), + ], + ) + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_COLLECTORS_START, + end_marker=_COMMAND_COLLECTORS_END, + new_lines=[f" {command_spec.collector_alias},"], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_IMPORTS_START, + end_marker=_SERVICE_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.services_module_prefix}.{command_spec.module_name} " + f"import {command_spec.service_class_name}" + ), + ], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_PROVIDERS_START, + end_marker=_SERVICE_PROVIDERS_END, + new_lines=[ + ( + f" {command_spec.service_provider_name} = providers.Factory(" + f"{command_spec.service_class_name})" + ), + ], + ) + + return CreatedCommandArtifacts( + project_root=project_root, + package_name=manifest.package_name, + command_path=command_spec.command_path, + created_files=( + command_module_path, + service_module_path, + test_module_path, + ), + ) + + +def _build_command_spec( + *, + command_name: str, + description: str | None, + response_text: str | None, +) -> _CommandSpec: + normalized_command = normalize_scaffold_name(command_name, kind="Command name") + module_name = slug_to_module_name(normalized_command) + service_class_name = f"{module_name_to_pascal_case(module_name)}Service" + + return _CommandSpec( + module_name=module_name, + command_path=f"/{normalized_command}", + description=description or f"Handle /{normalized_command} command", + response_text=response_text or f"Handled /{normalized_command} command.", + service_class_name=service_class_name, + service_provider_name=f"{module_name}_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + ) diff --git a/pybotx/scaffold/fsm_flow.py b/pybotx/scaffold/fsm_flow.py new file mode 100644 index 00000000..9b248f21 --- /dev/null +++ b/pybotx/scaffold/fsm_flow.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + FSM_FLOW_COMMAND_TEMPLATE, + FSM_FLOW_MODULE_TEMPLATE, + FSM_FLOW_TEST_TEMPLATE, +) + + +_COMMAND_IMPORTS_START = "# pybotx-scaffold command imports start" +_COMMAND_IMPORTS_END = "# pybotx-scaffold command imports end" +_COMMAND_COLLECTORS_START = " # pybotx-scaffold command collectors start" +_COMMAND_COLLECTORS_END = " # pybotx-scaffold command collectors end" +_FSM_IMPORTS_START = "# pybotx-scaffold fsm imports start" +_FSM_IMPORTS_END = "# pybotx-scaffold fsm imports end" +_FSM_COLLECTORS_START = " # pybotx-scaffold fsm collectors start" +_FSM_COLLECTORS_END = " # pybotx-scaffold fsm collectors end" + + +@dataclass(frozen=True, slots=True) +class CreateFSMFlowOptions: + flow_name: str + project_dir: Path | str = "." + command_name: str | None = None + description: str | None = None + initial_prompt: str | None = None + completion_text: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedFSMFlowArtifacts: + project_root: Path + package_name: str + flow_name: str + command_path: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _FSMFlowSpec: + module_name: str + flow_name: str + command_path: str + description: str + initial_prompt: str + handler_name: str + start_function_name: str + states_enum_name: str + collector_alias: str + fsm_alias: str + display_name: str + display_name_lower: str + completion_statement: str + completion_assert_text: str + + +def create_fsm_flow_in_project( + options: CreateFSMFlowOptions, +) -> CreatedFSMFlowArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + _ensure_fsm_template_support(project_root, manifest) + + flow_spec = _build_fsm_flow_spec( + flow_name=options.flow_name, + command_name=options.command_name, + description=options.description, + initial_prompt=options.initial_prompt, + completion_text=options.completion_text, + ) + + flow_module_path = project_root / manifest.fsm_dir / f"{flow_spec.module_name}.py" + command_module_path = project_root / manifest.commands_dir / ( + f"{flow_spec.module_name}.py" + ) + test_module_path = project_root / manifest.tests_dir / ( + f"test_{flow_spec.module_name}_fsm.py" + ) + + for path in (flow_module_path, command_module_path, test_module_path): + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + context = { + "command_literal": repr(flow_spec.command_path), + "completion_assert_literal": repr(flow_spec.completion_assert_text), + "completion_statement": flow_spec.completion_statement, + "description_literal": repr(flow_spec.description), + "display_name_lower": flow_spec.display_name_lower, + "fsm_module_prefix": manifest.fsm_module_prefix, + "handler_name": flow_spec.handler_name, + "initial_prompt_literal": repr(flow_spec.initial_prompt), + "module_name": flow_spec.module_name, + "package_name": manifest.package_name, + "start_function_name": flow_spec.start_function_name, + "states_enum_name": flow_spec.states_enum_name, + } + + write_template(flow_module_path, FSM_FLOW_MODULE_TEMPLATE, context) + write_template(command_module_path, FSM_FLOW_COMMAND_TEMPLATE, context) + write_template(test_module_path, FSM_FLOW_TEST_TEMPLATE, context) + + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_IMPORTS_START, + end_marker=_COMMAND_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.commands_module_prefix}.{flow_spec.module_name} " + f"import collector as {flow_spec.collector_alias}" + ), + ], + ) + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_COLLECTORS_START, + end_marker=_COMMAND_COLLECTORS_END, + new_lines=[f" {flow_spec.collector_alias},"], + ) + + fsm_import_line = ( + f"from {manifest.fsm_module_prefix}.{flow_spec.module_name} " + f"import fsm as {flow_spec.fsm_alias}" + ) + fsm_collector_line = f" {flow_spec.fsm_alias}," + + for relative_path in (manifest.bot_path, manifest.tests_conftest_path): + target_path = project_root / relative_path + append_marked_lines( + target_path, + start_marker=_FSM_IMPORTS_START, + end_marker=_FSM_IMPORTS_END, + new_lines=[fsm_import_line], + ) + append_marked_lines( + target_path, + start_marker=_FSM_COLLECTORS_START, + end_marker=_FSM_COLLECTORS_END, + new_lines=[fsm_collector_line], + ) + + return CreatedFSMFlowArtifacts( + project_root=project_root, + package_name=manifest.package_name, + flow_name=flow_spec.flow_name, + command_path=flow_spec.command_path, + created_files=( + flow_module_path, + command_module_path, + test_module_path, + ), + ) + + +def _build_fsm_flow_spec( + *, + flow_name: str, + command_name: str | None, + description: str | None, + initial_prompt: str | None, + completion_text: str | None, +) -> _FSMFlowSpec: + normalized_flow_name = normalize_scaffold_name(flow_name, kind="Flow name") + normalized_command_name = normalize_scaffold_name( + command_name or flow_name, + kind="Command name", + ) + module_name = slug_to_module_name(normalized_flow_name) + display_name_lower = normalized_flow_name.replace("-", " ") + display_name = display_name_lower.title() + states_enum_name = f"{module_name_to_pascal_case(module_name)}States" + + if completion_text is None: + completion_statement = ( + f'await bot.answer_message(f"{display_name} flow completed with value: {{value}}.")' + ) + completion_assert_text = f"{display_name} flow completed with value: sample-value." + else: + completion_statement = f"await bot.answer_message({completion_text!r})" + completion_assert_text = completion_text + + return _FSMFlowSpec( + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Start {display_name_lower} flow", + initial_prompt=initial_prompt or f"Enter {display_name_lower} value.", + handler_name=f"{module_name}_handler", + start_function_name=f"start_{module_name}_flow", + states_enum_name=states_enum_name, + collector_alias=f"{module_name}_collector", + fsm_alias=f"{module_name}_fsm", + display_name=display_name, + display_name_lower=display_name_lower, + completion_statement=completion_statement, + completion_assert_text=completion_assert_text, + ) + + +def _ensure_fsm_template_support( + project_root: Path, + manifest: ScaffoldProjectManifest, +) -> None: + if manifest.template != "production-fastapi-fsm": + raise ValueError( + "`pybotx create fsm-flow` is supported only for " + "`production-fastapi-fsm` projects", + ) + + for relative_path in (manifest.bot_path, manifest.tests_conftest_path): + _ensure_fsm_markers( + project_root / relative_path, + fsm_module_prefix=manifest.fsm_module_prefix, + package_name=manifest.package_name, + ) + + +def _ensure_fsm_markers( + path: Path, + *, + fsm_module_prefix: str, + package_name: str, +) -> None: + content = path.read_text(encoding="utf-8") + if _FSM_IMPORTS_START in content and _FSM_COLLECTORS_START in content: + return + + import_line = f"from {fsm_module_prefix}.login import fsm as login_fsm" + legacy_import_line = f"from {package_name}.fsm.login import fsm as login_fsm" + middlewares_line = ( + ' middlewares=[FSMMiddleware([login_fsm], state_repo_key="fsm_state_repo")],' + ) + + if import_line in content: + current_import_line = import_line + elif legacy_import_line in content: + current_import_line = legacy_import_line + else: + current_import_line = None + + if current_import_line is None or middlewares_line not in content: + raise RuntimeError( + "Couldn't enable FSM flow generation automatically for " + f"`{path}`. Regenerate the project or add scaffold markers manually.", + ) + + updated_content = content.replace( + current_import_line, + "\n".join( + [ + current_import_line, + _FSM_IMPORTS_START, + _FSM_IMPORTS_END, + ], + ), + 1, + ).replace( + middlewares_line, + "\n".join( + [ + " middlewares=[", + " FSMMiddleware(", + " [", + " login_fsm,", + _FSM_COLLECTORS_START, + _FSM_COLLECTORS_END, + " ],", + ' state_repo_key="fsm_state_repo",', + " ),", + " ],", + ], + ), + 1, + ) + path.write_text(updated_content, encoding="utf-8") diff --git a/pybotx/scaffold/manifest.py b/pybotx/scaffold/manifest.py new file mode 100644 index 00000000..103c3707 --- /dev/null +++ b/pybotx/scaffold/manifest.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +from pathlib import Path + + +PROJECT_MANIFEST_FILENAME = ".pybotx-project.json" +PROJECT_MANIFEST_SCHEMA_VERSION = 1 +PROJECT_LAYOUT_FLAT = "flat-v1" +PROJECT_LAYOUT_CLEAN_ARCHITECTURE = "clean-architecture-v1" + + +@dataclass(frozen=True, slots=True) +class ScaffoldProjectManifest: + template: str + package_name: str + layout: str = PROJECT_LAYOUT_FLAT + schema_version: int = PROJECT_MANIFEST_SCHEMA_VERSION + + def write(self, root_dir: Path) -> Path: + manifest_path = root_dir / PROJECT_MANIFEST_FILENAME + manifest_path.write_text( + json.dumps(asdict(self), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return manifest_path + + @classmethod + def load(cls, root_dir: Path) -> "ScaffoldProjectManifest": + manifest_path = root_dir / PROJECT_MANIFEST_FILENAME + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + if payload.get("schema_version") != PROJECT_MANIFEST_SCHEMA_VERSION: + raise RuntimeError( + "Unsupported scaffold manifest schema version " + f"`{payload.get('schema_version')}`", + ) + return cls(**payload) + + @property + def src_package_dir(self) -> Path: + return Path("src") / self.package_name + + @property + def commands_dir(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "presentation" / "commands" + return self.src_package_dir / "commands" + + @property + def commands_init_path(self) -> Path: + return self.commands_dir / "__init__.py" + + @property + def fsm_dir(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "presentation" / "fsm" + return self.src_package_dir / "fsm" + + @property + def widget_support_path(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "presentation" / "widget_support.py" + return self.src_package_dir / "widget_support.py" + + @property + def services_dir(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "application" / "services" + return self.src_package_dir / "services" + + @property + def bot_path(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "presentation" / "bot.py" + return self.src_package_dir / "bot.py" + + @property + def container_path(self) -> Path: + return self.src_package_dir / "container.py" + + @property + def config_path(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "infrastructure" / "config.py" + return self.src_package_dir / "config.py" + + @property + def api_path(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "presentation" / "api.py" + return self.src_package_dir / "main.py" + + @property + def fsm_state_repo_path(self) -> Path: + if self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE: + return self.src_package_dir / "infrastructure" / "fsm_state_repo.py" + return self.src_package_dir / "fsm_state_repo.py" + + @property + def architecture_doc_path(self) -> Path: + return Path("ARCHITECTURE.md") + + @property + def root_main_path(self) -> Path: + return self.src_package_dir / "main.py" + + @property + def domain_dir(self) -> Path: + return self.src_package_dir / "domain" + + @property + def application_dir(self) -> Path: + return self.src_package_dir / "application" + + @property + def application_ports_dir(self) -> Path: + return self.application_dir / "ports" + + @property + def infrastructure_dir(self) -> Path: + return self.src_package_dir / "infrastructure" + + @property + def infrastructure_repositories_dir(self) -> Path: + return self.infrastructure_dir / "repositories" + + @property + def presentation_dir(self) -> Path: + return self.src_package_dir / "presentation" + + @property + def domain_ports_dir(self) -> Path: + return self.domain_dir / "ports" + + @property + def commands_module_prefix(self) -> str: + return self._module_for_dir(self.commands_dir) + + @property + def services_module_prefix(self) -> str: + return self._module_for_dir(self.services_dir) + + @property + def fsm_module_prefix(self) -> str: + return self._module_for_dir(self.fsm_dir) + + @property + def container_module(self) -> str: + return self._module_for_file(self.container_path) + + @property + def widget_support_module(self) -> str: + return self._module_for_file(self.widget_support_path) + + @property + def bot_module(self) -> str: + return self._module_for_file(self.bot_path) + + @property + def config_module(self) -> str: + return self._module_for_file(self.config_path) + + @property + def api_module(self) -> str: + return self._module_for_file(self.api_path) + + @property + def fsm_state_repo_module(self) -> str: + return self._module_for_file(self.fsm_state_repo_path) + + @property + def root_main_module(self) -> str: + return self._module_for_file(self.root_main_path) + + @property + def uses_clean_architecture(self) -> bool: + return self.layout == PROJECT_LAYOUT_CLEAN_ARCHITECTURE + + def _module_for_dir(self, path: Path) -> str: + return ".".join(path.parts[1:]) + + def _module_for_file(self, path: Path) -> str: + suffixless_path = path.with_suffix("") + if suffixless_path.name == "__init__": + return ".".join(suffixless_path.parts[1:-1]) + return ".".join(suffixless_path.parts[1:]) + + @property + def architecture_test_path(self) -> Path: + return self.tests_dir / "test_architecture.py" + + @property + def layer_paths(self) -> dict[str, Path]: + return { + "domain": self.domain_dir, + "application": self.application_dir, + "infrastructure": self.infrastructure_dir, + "presentation": self.presentation_dir, + } + + @property + def tests_conftest_path(self) -> Path: + return self.tests_dir / "conftest.py" + + @property + def tests_dir(self) -> Path: + return Path("tests") + + +def find_scaffold_project_root(start_dir: Path | str) -> Path: + current_path = Path(start_dir).expanduser().resolve() + if current_path.is_file(): + current_path = current_path.parent + + for candidate in (current_path, *current_path.parents): + if (candidate / PROJECT_MANIFEST_FILENAME).is_file(): + return candidate + + raise FileNotFoundError( + "Couldn't find `.pybotx-project.json`. " + "Run the command inside a project generated by `pybotx create bot`.", + ) diff --git a/pybotx/scaffold/port.py b/pybotx/scaffold/port.py new file mode 100644 index 00000000..f108da42 --- /dev/null +++ b/pybotx/scaffold/port.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from pybotx.scaffold._shared import ( + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import PORT_TEMPLATE + + +@dataclass(frozen=True, slots=True) +class CreatePortOptions: + port_name: str + project_dir: Path | str = "." + + +@dataclass(frozen=True, slots=True) +class CreatedPortArtifacts: + project_root: Path + package_name: str + port_name: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _PortSpec: + normalized_name: str + module_name: str + port_class_name: str + + +def create_port_in_project(options: CreatePortOptions) -> CreatedPortArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + port_spec = _build_port_spec(options.port_name) + + port_module_path = project_root / manifest.domain_ports_dir / ( + f"{port_spec.module_name}.py" + ) + if port_module_path.exists(): + raise FileExistsError(f"`{port_module_path}` already exists") + + context = { + "port_class_name": port_spec.port_class_name, + "port_slug": port_spec.normalized_name, + } + write_template(port_module_path, PORT_TEMPLATE, context) + + return CreatedPortArtifacts( + project_root=project_root, + package_name=manifest.package_name, + port_name=port_spec.module_name, + created_files=(port_module_path,), + ) + + +def _build_port_spec(port_name: str) -> _PortSpec: + normalized_port_name = normalize_scaffold_name( + port_name, + kind="Port name", + ) + module_name = slug_to_module_name(normalized_port_name) + + return _PortSpec( + normalized_name=normalized_port_name, + module_name=module_name, + port_class_name=f"{module_name_to_pascal_case(module_name)}Port", + ) diff --git a/pybotx/scaffold/project.py b/pybotx/scaffold/project.py new file mode 100644 index 00000000..477c7b0f --- /dev/null +++ b/pybotx/scaffold/project.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from dataclasses import dataclass +import importlib.metadata +import keyword +from pathlib import Path +import re +from string import Template +from typing import Literal + +from pybotx.scaffold.manifest import ( + PROJECT_LAYOUT_CLEAN_ARCHITECTURE, + ScaffoldProjectManifest, +) +from pybotx.scaffold.templates import ( + APPLICATION_INIT_TEMPLATE, + APPLICATION_PORTS_INIT_TEMPLATE, + ARCHITECTURE_TEMPLATE, + ARCHITECTURE_TEST_TEMPLATE, + AUTH_SERVICE_TEMPLATE, + BOT_TEMPLATE, + BOT_FSM_TEMPLATE, + COMMANDS_INIT_TEMPLATE, + COMMANDS_INIT_FSM_TEMPLATE, + COMMON_COMMANDS_TEMPLATE, + COMMON_COMMANDS_FSM_TEMPLATE, + CONFIG_TEMPLATE, + CONFIG_FSM_TEMPLATE, + CONTAINER_TEMPLATE, + CONTAINER_FSM_TEMPLATE, + DOMAIN_PORTS_INIT_TEMPLATE, + DOCKER_COMPOSE_TEMPLATE, + DOCKERFILE_TEMPLATE, + ENV_EXAMPLE_TEMPLATE, + ENV_EXAMPLE_FSM_TEMPLATE, + FSM_INIT_TEMPLATE, + FSM_LOGIN_TEMPLATE, + FSM_STATE_REPO_TEMPLATE, + GREETING_PROFILE_REPOSITORY_TEMPLATE, + GREETING_PROFILE_TEMPLATE, + GITIGNORE_TEMPLATE, + GREETING_SERVICE_TEMPLATE, + INFRASTRUCTURE_REPOSITORIES_INIT_TEMPLATE, + MAIN_TEMPLATE, + MAIN_FSM_TEMPLATE, + PACKAGE_INIT_TEMPLATE, + PRESENTATION_API_TEMPLATE, + PRESENTATION_API_FSM_TEMPLATE, + PRESENTATION_INIT_TEMPLATE, + PYPROJECT_TEMPLATE, + PYPROJECT_FSM_TEMPLATE, + README_TEMPLATE, + README_FSM_TEMPLATE, + SERVICES_INIT_TEMPLATE, + STATIC_GREETING_PROFILE_REPOSITORY_TEMPLATE, + TESTS_CONFTEST_TEMPLATE, + TESTS_CONFTEST_FSM_TEMPLATE, + TEST_START_COMMAND_TEMPLATE, + TEST_START_COMMAND_FSM_TEMPLATE, + DOMAIN_INIT_TEMPLATE, + INFRASTRUCTURE_INIT_TEMPLATE, + WIDGET_SUPPORT_TEMPLATE, +) + +TemplateName = Literal["production-fastapi", "production-fastapi-fsm"] + +_SUPPORTED_TEMPLATES: tuple[TemplateName, ...] = ( + "production-fastapi", + "production-fastapi-fsm", +) +_NON_ALNUM_RE = re.compile(r"[^0-9a-zA-Z_]+") +_VERSION_RE = re.compile(r'^version = "(?P[^"]+)"$', re.MULTILINE) + + +@dataclass(frozen=True, slots=True) +class CreateBotProjectOptions: + target_dir: Path | str + template: TemplateName = "production-fastapi" + project_name: str | None = None + package_name: str | None = None + display_name: str | None = None + description: str | None = None + enable_otel_tracing: bool = False + include_docker: bool = True + + +@dataclass(frozen=True, slots=True) +class CreatedBotProject: + root_dir: Path + template: TemplateName + project_name: str + package_name: str + created_files: tuple[Path, ...] + + +def create_bot_project(options: CreateBotProjectOptions) -> CreatedBotProject: + target_dir = Path(options.target_dir).expanduser().resolve() + _validate_template(options.template) + _ensure_target_dir_is_safe(target_dir) + + project_name = options.project_name or target_dir.name + if not project_name: + raise ValueError("Project name couldn't be derived from target directory") + + package_name = _normalize_package_name(options.package_name or project_name) + display_name = options.display_name or _build_display_name(project_name) + description = options.description or f"{display_name} bot built with pybotx" + manifest = ScaffoldProjectManifest( + template=options.template, + package_name=package_name, + layout=PROJECT_LAYOUT_CLEAN_ARCHITECTURE, + ) + context = { + "api_module": manifest.api_module, + "bot_module": manifest.bot_module, + "commands_module_prefix": manifest.commands_module_prefix, + "config_module": manifest.config_module, + "container_module": manifest.container_module, + "description": description, + "display_name": display_name, + "enable_otel_tracing": str(options.enable_otel_tracing).lower(), + "enable_otel_tracing_python": "True" if options.enable_otel_tracing else "False", + "fsm_state_repo_module": manifest.fsm_state_repo_module, + "fsm_module_prefix": manifest.fsm_module_prefix, + "greeting_profile_module": f"{package_name}.domain.greeting_profile", + "greeting_profile_repository_module": ( + f"{package_name}.domain.ports.greeting_profile_repository" + ), + "package_name": package_name, + "project_name": project_name, + "services_module_prefix": manifest.services_module_prefix, + "static_greeting_profile_repository_module": ( + f"{package_name}.infrastructure.repositories.static_greeting_profile_repository" + ), + "widget_support_module": manifest.widget_support_module, + "pybotx_version": _resolve_pybotx_version(), + } + + target_dir.mkdir(parents=True, exist_ok=True) + created_files: list[Path] = [] + for relative_path, template in _build_project_templates( + manifest=manifest, + include_docker=options.include_docker, + ): + absolute_path = target_dir / relative_path + absolute_path.parent.mkdir(parents=True, exist_ok=True) + absolute_path.write_text(template.safe_substitute(context), encoding="utf-8") + created_files.append(absolute_path) + + manifest_path = manifest.write(target_dir) + created_files.append(manifest_path) + + return CreatedBotProject( + root_dir=target_dir, + template=options.template, + project_name=project_name, + package_name=package_name, + created_files=tuple(created_files), + ) + + +def _validate_template(template: str) -> None: + if template not in _SUPPORTED_TEMPLATES: + raise ValueError( + f"Unsupported template `{template}`. " + f"Supported templates: {', '.join(_SUPPORTED_TEMPLATES)}", + ) + + +def _ensure_target_dir_is_safe(target_dir: Path) -> None: + if target_dir.exists(): + if not target_dir.is_dir(): + raise FileExistsError(f"Target path `{target_dir}` already exists and isn't a directory") + if any(target_dir.iterdir()): + raise FileExistsError(f"Target directory `{target_dir}` isn't empty") + + +def _normalize_package_name(raw_name: str) -> str: + normalized = _NON_ALNUM_RE.sub("_", raw_name.strip().lower()).strip("_") + if not normalized: + raise ValueError("Package name can't be empty") + if normalized[0].isdigit(): + raise ValueError("Package name should not start with a digit") + if not normalized.isidentifier(): + raise ValueError(f"Invalid package name `{normalized}`") + if keyword.iskeyword(normalized): + raise ValueError(f"Package name `{normalized}` is a reserved Python keyword") + return normalized + + +def _build_display_name(project_name: str) -> str: + tokens = [token for token in _NON_ALNUM_RE.split(project_name) if token] + if not tokens: + raise ValueError("Display name couldn't be derived from project name") + return " ".join(token.capitalize() for token in tokens) + + +def _resolve_pybotx_version() -> str: + try: + return importlib.metadata.version("pybotx") + except importlib.metadata.PackageNotFoundError: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + pyproject_content = pyproject_path.read_text(encoding="utf-8") + match = _VERSION_RE.search(pyproject_content) + if match is None: + raise RuntimeError("Couldn't resolve current pybotx version from pyproject.toml") + return match.group("version") + + +def _build_project_templates( + *, + manifest: ScaffoldProjectManifest, + include_docker: bool, +) -> tuple[tuple[Path, Template], ...]: + src_root = manifest.src_package_dir + if manifest.template == "production-fastapi": + template_items: list[tuple[Path, Template]] = [ + (Path(".env.example"), ENV_EXAMPLE_TEMPLATE), + (Path(".gitignore"), GITIGNORE_TEMPLATE), + (manifest.architecture_doc_path, ARCHITECTURE_TEMPLATE), + (Path("README.md"), README_TEMPLATE), + (Path("pyproject.toml"), PYPROJECT_TEMPLATE), + (src_root / "__init__.py", PACKAGE_INIT_TEMPLATE), + (manifest.root_main_path, MAIN_TEMPLATE), + (manifest.domain_dir / "__init__.py", DOMAIN_INIT_TEMPLATE), + (manifest.domain_ports_dir / "__init__.py", DOMAIN_PORTS_INIT_TEMPLATE), + ( + manifest.domain_dir / "greeting_profile.py", + GREETING_PROFILE_TEMPLATE, + ), + ( + manifest.domain_ports_dir / "greeting_profile_repository.py", + GREETING_PROFILE_REPOSITORY_TEMPLATE, + ), + (manifest.application_dir / "__init__.py", APPLICATION_INIT_TEMPLATE), + ( + manifest.application_ports_dir / "__init__.py", + APPLICATION_PORTS_INIT_TEMPLATE, + ), + (manifest.services_dir / "__init__.py", SERVICES_INIT_TEMPLATE), + (manifest.services_dir / "greeting.py", GREETING_SERVICE_TEMPLATE), + (manifest.infrastructure_dir / "__init__.py", INFRASTRUCTURE_INIT_TEMPLATE), + ( + manifest.infrastructure_repositories_dir / "__init__.py", + INFRASTRUCTURE_REPOSITORIES_INIT_TEMPLATE, + ), + ( + manifest.infrastructure_repositories_dir + / "static_greeting_profile_repository.py", + STATIC_GREETING_PROFILE_REPOSITORY_TEMPLATE, + ), + (manifest.config_path, CONFIG_TEMPLATE), + (manifest.container_path, CONTAINER_TEMPLATE), + (manifest.presentation_dir / "__init__.py", PRESENTATION_INIT_TEMPLATE), + (manifest.api_path, PRESENTATION_API_TEMPLATE), + (manifest.bot_path, BOT_TEMPLATE), + (manifest.widget_support_path, WIDGET_SUPPORT_TEMPLATE), + (manifest.commands_init_path, COMMANDS_INIT_TEMPLATE), + (manifest.commands_dir / "common.py", COMMON_COMMANDS_TEMPLATE), + (manifest.tests_conftest_path, TESTS_CONFTEST_TEMPLATE), + (manifest.architecture_test_path, ARCHITECTURE_TEST_TEMPLATE), + (manifest.tests_dir / "test_start_command.py", TEST_START_COMMAND_TEMPLATE), + ] + else: + template_items = [ + (Path(".env.example"), ENV_EXAMPLE_FSM_TEMPLATE), + (Path(".gitignore"), GITIGNORE_TEMPLATE), + (manifest.architecture_doc_path, ARCHITECTURE_TEMPLATE), + (Path("README.md"), README_FSM_TEMPLATE), + (Path("pyproject.toml"), PYPROJECT_FSM_TEMPLATE), + (src_root / "__init__.py", PACKAGE_INIT_TEMPLATE), + (manifest.root_main_path, MAIN_FSM_TEMPLATE), + (manifest.domain_dir / "__init__.py", DOMAIN_INIT_TEMPLATE), + (manifest.domain_ports_dir / "__init__.py", DOMAIN_PORTS_INIT_TEMPLATE), + ( + manifest.domain_dir / "greeting_profile.py", + GREETING_PROFILE_TEMPLATE, + ), + ( + manifest.domain_ports_dir / "greeting_profile_repository.py", + GREETING_PROFILE_REPOSITORY_TEMPLATE, + ), + (manifest.application_dir / "__init__.py", APPLICATION_INIT_TEMPLATE), + ( + manifest.application_ports_dir / "__init__.py", + APPLICATION_PORTS_INIT_TEMPLATE, + ), + (manifest.services_dir / "__init__.py", SERVICES_INIT_TEMPLATE), + (manifest.services_dir / "auth.py", AUTH_SERVICE_TEMPLATE), + (manifest.services_dir / "greeting.py", GREETING_SERVICE_TEMPLATE), + (manifest.infrastructure_dir / "__init__.py", INFRASTRUCTURE_INIT_TEMPLATE), + ( + manifest.infrastructure_repositories_dir / "__init__.py", + INFRASTRUCTURE_REPOSITORIES_INIT_TEMPLATE, + ), + ( + manifest.infrastructure_repositories_dir + / "static_greeting_profile_repository.py", + STATIC_GREETING_PROFILE_REPOSITORY_TEMPLATE, + ), + (manifest.config_path, CONFIG_FSM_TEMPLATE), + (manifest.container_path, CONTAINER_FSM_TEMPLATE), + (manifest.fsm_state_repo_path, FSM_STATE_REPO_TEMPLATE), + (manifest.presentation_dir / "__init__.py", PRESENTATION_INIT_TEMPLATE), + (manifest.api_path, PRESENTATION_API_FSM_TEMPLATE), + (manifest.bot_path, BOT_FSM_TEMPLATE), + (manifest.widget_support_path, WIDGET_SUPPORT_TEMPLATE), + (manifest.commands_init_path, COMMANDS_INIT_FSM_TEMPLATE), + (manifest.commands_dir / "common.py", COMMON_COMMANDS_FSM_TEMPLATE), + (manifest.fsm_dir / "__init__.py", FSM_INIT_TEMPLATE), + (manifest.fsm_dir / "login.py", FSM_LOGIN_TEMPLATE), + (manifest.tests_conftest_path, TESTS_CONFTEST_FSM_TEMPLATE), + (manifest.architecture_test_path, ARCHITECTURE_TEST_TEMPLATE), + (manifest.tests_dir / "test_start_command.py", TEST_START_COMMAND_FSM_TEMPLATE), + ] + if include_docker: + template_items.extend( + [ + (Path("Dockerfile"), DOCKERFILE_TEMPLATE), + (Path("docker-compose.yml"), DOCKER_COMPOSE_TEMPLATE), + ], + ) + return tuple(template_items) diff --git a/pybotx/scaffold/repository.py b/pybotx/scaffold/repository.py new file mode 100644 index 00000000..01b8f4e2 --- /dev/null +++ b/pybotx/scaffold/repository.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + REPOSITORY_IMPLEMENTATION_TEMPLATE, + REPOSITORY_PORT_TEMPLATE, + REPOSITORY_TEST_TEMPLATE, +) + +_REPOSITORY_IMPORTS_START = "# pybotx-scaffold repository imports start" +_REPOSITORY_IMPORTS_END = "# pybotx-scaffold repository imports end" +_REPOSITORY_PROVIDERS_START = " # pybotx-scaffold repository providers start" +_REPOSITORY_PROVIDERS_END = " # pybotx-scaffold repository providers end" + +_FALLBACK_IMPORTS_START = "# pybotx-scaffold service imports start" +_FALLBACK_IMPORTS_END = "# pybotx-scaffold service imports end" +_FALLBACK_PROVIDERS_START = " # pybotx-scaffold service providers start" +_FALLBACK_PROVIDERS_END = " # pybotx-scaffold service providers end" + + +@dataclass(frozen=True, slots=True) +class CreateRepositoryOptions: + repository_name: str + project_dir: Path | str = "." + port_name: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedRepositoryArtifacts: + project_root: Path + package_name: str + repository_name: str + port_name: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _RepositorySpec: + repository_slug: str + repository_module_name: str + implementation_module_name: str + port_slug: str + port_module_name: str + port_class_name: str + implementation_class_name: str + repository_provider_name: str + test_module_name: str + should_create_port: bool + + +def create_repository_in_project( + options: CreateRepositoryOptions, +) -> CreatedRepositoryArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + repository_spec = _build_repository_spec( + repository_name=options.repository_name, + port_name=options.port_name, + ) + + port_module_path = project_root / manifest.domain_ports_dir / ( + f"{repository_spec.port_module_name}.py" + ) + repository_module_path = project_root / manifest.infrastructure_repositories_dir / ( + f"{repository_spec.implementation_module_name}.py" + ) + test_module_path = project_root / manifest.tests_dir / ( + f"{repository_spec.test_module_name}.py" + ) + + paths_to_check = [repository_module_path, test_module_path] + if repository_spec.should_create_port: + paths_to_check.append(port_module_path) + else: + if not port_module_path.exists(): + raise FileNotFoundError( + f"Port `{repository_spec.port_module_name}` not found at `{port_module_path}`", + ) + + for path in paths_to_check: + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + context = { + "container_module": manifest.container_module, + "implementation_class_name": repository_spec.implementation_class_name, + "module_name": repository_spec.repository_module_name, + "port_class_name": repository_spec.port_class_name, + "repository_module": ( + f"{manifest.package_name}.infrastructure.repositories." + f"{repository_spec.implementation_module_name}" + ), + "repository_provider_name": repository_spec.repository_provider_name, + "repository_slug": repository_spec.repository_slug, + } + + if repository_spec.should_create_port: + write_template(port_module_path, REPOSITORY_PORT_TEMPLATE, context) + write_template( + repository_module_path, + REPOSITORY_IMPLEMENTATION_TEMPLATE, + context, + ) + write_template(test_module_path, REPOSITORY_TEST_TEMPLATE, context) + + imports_start, imports_end = _resolve_container_markers( + project_root / manifest.container_path, + preferred_start=_REPOSITORY_IMPORTS_START, + preferred_end=_REPOSITORY_IMPORTS_END, + fallback_start=_FALLBACK_IMPORTS_START, + fallback_end=_FALLBACK_IMPORTS_END, + ) + providers_start, providers_end = _resolve_container_markers( + project_root / manifest.container_path, + preferred_start=_REPOSITORY_PROVIDERS_START, + preferred_end=_REPOSITORY_PROVIDERS_END, + fallback_start=_FALLBACK_PROVIDERS_START, + fallback_end=_FALLBACK_PROVIDERS_END, + ) + + append_marked_lines( + project_root / manifest.container_path, + start_marker=imports_start, + end_marker=imports_end, + new_lines=[ + ( + "from " + f"{manifest.package_name}.infrastructure.repositories." + f"{repository_spec.implementation_module_name} " + f"import {repository_spec.implementation_class_name}" + ), + ], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=providers_start, + end_marker=providers_end, + new_lines=[ + ( + f" {repository_spec.repository_provider_name} = providers.Singleton(" + f"{repository_spec.implementation_class_name})" + ), + ], + ) + + return CreatedRepositoryArtifacts( + project_root=project_root, + package_name=manifest.package_name, + repository_name=repository_spec.repository_module_name, + port_name=repository_spec.port_module_name, + created_files=tuple(paths_to_check), + ) + + +def _build_repository_spec( + *, + repository_name: str, + port_name: str | None, +) -> _RepositorySpec: + normalized_repository_name = normalize_scaffold_name( + repository_name, + kind="Repository name", + ) + repository_module_base = slug_to_module_name(normalized_repository_name) + + if port_name is None: + port_slug = normalized_repository_name + port_module_name = f"{repository_module_base}_repository" + port_class_name = f"{module_name_to_pascal_case(repository_module_base)}Repository" + repository_module_name = port_module_name + implementation_module_name = f"stub_{repository_module_name}" + implementation_class_name = f"Stub{port_class_name}" + repository_provider_name = port_module_name + test_module_name = f"test_{repository_module_name}" + should_create_port = True + else: + normalized_port_name = normalize_scaffold_name( + port_name, + kind="Port name", + ) + port_module_name = slug_to_module_name(normalized_port_name) + port_class_name = module_name_to_pascal_case(port_module_name) + if not port_module_name.endswith("_repository"): + port_class_name = f"{port_class_name}Port" + repository_module_name = f"{repository_module_base}_repository" + implementation_module_name = f"stub_{repository_module_name}" + implementation_class_name = ( + f"Stub{module_name_to_pascal_case(repository_module_base)}Repository" + ) + repository_provider_name = port_module_name + test_module_name = f"test_{repository_module_name}" + port_slug = normalized_port_name + should_create_port = False + + return _RepositorySpec( + repository_slug=normalized_repository_name, + repository_module_name=repository_module_name, + implementation_module_name=implementation_module_name, + port_slug=port_slug, + port_module_name=port_module_name, + port_class_name=port_class_name, + implementation_class_name=implementation_class_name, + repository_provider_name=repository_provider_name, + test_module_name=test_module_name, + should_create_port=should_create_port, + ) + + +def _resolve_container_markers( + path: Path, + *, + preferred_start: str, + preferred_end: str, + fallback_start: str, + fallback_end: str, +) -> tuple[str, str]: + content = path.read_text(encoding="utf-8") + if preferred_start in content and preferred_end in content: + return preferred_start, preferred_end + if fallback_start in content and fallback_end in content: + return fallback_start, fallback_end + raise RuntimeError(f"Couldn't find scaffold markers in `{path}`") diff --git a/pybotx/scaffold/service.py b/pybotx/scaffold/service.py new file mode 100644 index 00000000..062c6f20 --- /dev/null +++ b/pybotx/scaffold/service.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + APPLICATION_SERVICE_TEST_TEMPLATE, + SERVICE_MODULE_TEMPLATE, +) + +_SERVICE_IMPORTS_START = "# pybotx-scaffold service imports start" +_SERVICE_IMPORTS_END = "# pybotx-scaffold service imports end" +_SERVICE_PROVIDERS_START = " # pybotx-scaffold service providers start" +_SERVICE_PROVIDERS_END = " # pybotx-scaffold service providers end" + + +@dataclass(frozen=True, slots=True) +class CreateServiceOptions: + service_name: str + project_dir: Path | str = "." + response_text: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedServiceArtifacts: + project_root: Path + package_name: str + service_name: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _ServiceSpec: + module_name: str + response_text: str + service_class_name: str + service_provider_name: str + test_module_name: str + + +def create_service_in_project( + options: CreateServiceOptions, +) -> CreatedServiceArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + service_spec = _build_service_spec( + service_name=options.service_name, + response_text=options.response_text, + ) + + service_module_path = project_root / manifest.services_dir / ( + f"{service_spec.module_name}.py" + ) + test_module_path = project_root / manifest.tests_dir / ( + f"{service_spec.test_module_name}.py" + ) + + for path in (service_module_path, test_module_path): + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + context = { + "module_name": service_spec.module_name, + "response_text_literal": repr(service_spec.response_text), + "service_class_name": service_spec.service_class_name, + "services_module_prefix": manifest.services_module_prefix, + } + + write_template(service_module_path, SERVICE_MODULE_TEMPLATE, context) + write_template(test_module_path, APPLICATION_SERVICE_TEST_TEMPLATE, context) + + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_IMPORTS_START, + end_marker=_SERVICE_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.services_module_prefix}.{service_spec.module_name} " + f"import {service_spec.service_class_name}" + ), + ], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_PROVIDERS_START, + end_marker=_SERVICE_PROVIDERS_END, + new_lines=[ + ( + f" {service_spec.service_provider_name} = providers.Factory(" + f"{service_spec.service_class_name})" + ), + ], + ) + + return CreatedServiceArtifacts( + project_root=project_root, + package_name=manifest.package_name, + service_name=service_spec.module_name, + created_files=( + service_module_path, + test_module_path, + ), + ) + + +def _build_service_spec( + *, + service_name: str, + response_text: str | None, +) -> _ServiceSpec: + normalized_service_name = normalize_scaffold_name( + service_name, + kind="Service name", + ) + module_name = slug_to_module_name(normalized_service_name) + + return _ServiceSpec( + module_name=module_name, + response_text=response_text or f"Implement {normalized_service_name} service.", + service_class_name=f"{module_name_to_pascal_case(module_name)}Service", + service_provider_name=f"{module_name}_service", + test_module_name=f"test_{module_name}_service", + ) diff --git a/pybotx/scaffold/templates.py b/pybotx/scaffold/templates.py new file mode 100644 index 00000000..83e530e9 --- /dev/null +++ b/pybotx/scaffold/templates.py @@ -0,0 +1,3245 @@ +from string import Template + + +GITIGNORE_TEMPLATE = Template( + """__pycache__/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.venv/ +dist/ +build/ +*.pyc +.env +""" +) + + +ENV_EXAMPLE_TEMPLATE = Template( + """BOT_ID="123e4567-e89b-12d3-a456-426655440000" +BOT_CTS_URL="https://cts.example.com" +BOT_SECRET_KEY="replace-with-real-secret" + +BOT_DISPLAY_NAME="$display_name" + +BOT_COMMAND_MAX_CONCURRENCY="32" +BOT_COMMAND_MAX_QUEUE_SIZE="512" +BOT_COMMAND_OVERLOAD_ACTION="reject_new" + +BOT_ENABLE_OTEL_TRACING="${enable_otel_tracing}" +BOT_OTEL_TRACER_NAME="$package_name.bot" +""" +) + + +ENV_EXAMPLE_FSM_TEMPLATE = Template( + """BOT_ID="123e4567-e89b-12d3-a456-426655440000" +BOT_CTS_URL="https://cts.example.com" +BOT_SECRET_KEY="replace-with-real-secret" + +BOT_DISPLAY_NAME="$display_name" + +BOT_COMMAND_MAX_CONCURRENCY="32" +BOT_COMMAND_MAX_QUEUE_SIZE="512" +BOT_COMMAND_OVERLOAD_ACTION="reject_new" + +BOT_ENABLE_OTEL_TRACING="${enable_otel_tracing}" +BOT_OTEL_TRACER_NAME="$package_name.bot" + +BOT_FSM_REDIS_URL="redis://localhost:6379/0" +BOT_FSM_STATE_TTL_SECONDS="900" +""" +) + + +README_TEMPLATE = Template( + """# $project_name + +Production-ready `pybotx` bot scaffold generated by `pybotx create bot`. + +## What is included + +- FastAPI integration for `/command`, `/status`, `/notification/callback` +- library-owned FastAPI adapter from `pybotx` +- `/metrics` with shared Prometheus registry +- `pybotx` production bot preset +- structured JSON logs from `pybotx` +- healthcheck endpoints from `pybotx` +- command processing limits and backpressure config +- `dependency-injector` container +- 4-layer clean architecture: `domain / application / infrastructure / presentation` +- sample domain port and repository stub wired through DI +- sample `/start` and `/help` commands +- pytest/ruff/mypy configuration + +## Setup + +```bash +uv sync +cp .env.example .env +``` + +Fill real values in `.env`: + +- `BOT_ID` +- `BOT_CTS_URL` +- `BOT_SECRET_KEY` + +Optional: + +- `BOT_ENABLE_OTEL_TRACING=true` if you want OpenTelemetry tracing + +## Run + +```bash +uv run uvicorn $package_name.main:app --host 0.0.0.0 --port 8000 --reload +``` + +## Docker + +```bash +docker compose up --build +``` + +## Add a command + +```bash +uv run pybotx create command ping-users --project-dir . +``` + +## Add an application service + +```bash +uv run pybotx create service sync-users --project-dir . +``` + +## Add a port + +```bash +uv run pybotx create port billing-gateway --project-dir . +``` + +## Add a repository + +```bash +uv run pybotx create repository user-profile --project-dir . +``` + +To bind a repository stub to an existing port: + +```bash +uv run pybotx create repository stripe-billing-gateway --project-dir . --port billing-gateway +``` + +## Add a widget + +```bash +uv run pybotx create widget deployment-approval --project-dir . --kind confirm +``` + +## Add a widget flow + +```bash +uv run pybotx create widget-flow deployment-approval --project-dir . --kind confirm +``` + +## Endpoints + +- `POST /command` +- `GET /status` +- `POST /notification/callback` +- `GET /metrics` +- `GET /health/` +- `GET /health/ready` + +## Architecture + +- Layering rules and examples live in `ARCHITECTURE.md` +- Composition root lives in `src/$package_name/container.py` +- `pybotx` handlers stay in `src/$package_name/presentation/` +- application services live in `src/$package_name/application/services/` +- domain ports live in `src/$package_name/domain/ports/` +- repository implementations live in `src/$package_name/infrastructure/repositories/` + +## Tests + +```bash +uv run pytest +uv run ruff check . +uv run mypy src tests +``` +""" +) + + +README_FSM_TEMPLATE = Template( + """# $project_name + +Production-ready `pybotx` + `pybotx-fsm` bot scaffold generated by `pybotx create bot`. + +## What is included + +- FastAPI integration for `/command`, `/status`, `/notification/callback` +- library-owned FastAPI adapter from `pybotx` +- `/metrics` with shared Prometheus registry +- `pybotx` production bot preset +- `pybotx-fsm` middleware and sample login flow +- `RedisFSMStateRepo` for FSM state persistence +- readiness check for Redis-backed FSM storage +- structured JSON logs from `pybotx` +- healthcheck endpoints from `pybotx` +- command processing limits and backpressure config +- `dependency-injector` container +- 4-layer clean architecture: `domain / application / infrastructure / presentation` +- sample domain port and repository stub wired through DI +- sample `/start`, `/help`, `/login`, `/cancel` commands +- pytest/ruff/mypy configuration + +## Setup + +```bash +uv sync +cp .env.example .env +``` + +Fill real values in `.env`: + +- `BOT_ID` +- `BOT_CTS_URL` +- `BOT_SECRET_KEY` +- `BOT_FSM_REDIS_URL` + +Optional: + +- `BOT_ENABLE_OTEL_TRACING=true` if you want OpenTelemetry tracing + +## Run + +```bash +uv run uvicorn $package_name.main:app --host 0.0.0.0 --port 8000 --reload +``` + +## Docker + +```bash +docker compose up --build +``` + +## Add a command + +```bash +uv run pybotx create command ping-users --project-dir . +``` + +## Add an application service + +```bash +uv run pybotx create service sync-users --project-dir . +``` + +## Add a port + +```bash +uv run pybotx create port billing-gateway --project-dir . +``` + +## Add a repository + +```bash +uv run pybotx create repository user-profile --project-dir . +``` + +To bind a repository stub to an existing port: + +```bash +uv run pybotx create repository stripe-billing-gateway --project-dir . --port billing-gateway +``` + +## Add an FSM flow + +```bash +uv run pybotx create fsm-flow approval --project-dir . +``` + +## Add a widget + +```bash +uv run pybotx create widget deployment-approval --project-dir . --kind confirm +``` + +## Add a widget flow + +```bash +uv run pybotx create widget-flow deployment-approval --project-dir . --kind confirm +``` + +## Endpoints + +- `POST /command` +- `GET /status` +- `POST /notification/callback` +- `GET /metrics` +- `GET /health/` +- `GET /health/ready` + +## Architecture + +- Layering rules and examples live in `ARCHITECTURE.md` +- Composition root lives in `src/$package_name/container.py` +- `pybotx` handlers and FSM flows live in `src/$package_name/presentation/` +- Redis-backed FSM storage lives in `src/$package_name/infrastructure/` +- domain ports live in `src/$package_name/domain/ports/` +- repository implementations live in `src/$package_name/infrastructure/repositories/` + +## FSM demo + +Send `/login`, then use: + +- `demo@example.com` +- `password123` + +## Tests + +```bash +uv run pytest +uv run ruff check . +uv run mypy src tests +``` +""" +) + + +PYPROJECT_TEMPLATE = Template( + """[project] +name = "$project_name" +version = "0.1.0" +description = "$description" +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "pybotx==$pybotx_version", + "dependency-injector>=4.48.1,<5", + "fastapi>=0.128.0,<0.129", + "opentelemetry-api>=1.38.0,<2", + "prometheus-client>=0.22.1,<0.23", + "pydantic-settings>=2.10.1,<3", + "uvicorn>=0.40.0,<0.41", +] + +[dependency-groups] +dev = [ + "mypy>=1.19.1,<2", + "pytest>=9.0.2,<10", + "pytest-asyncio>=1.3.0,<2", + "ruff>=0.15.0,<0.16", +] + +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/$package_name"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.mypy] +python_version = "3.10" +strict = true +mypy_path = ["src"] +""" +) + + +PYPROJECT_FSM_TEMPLATE = Template( + """[project] +name = "$project_name" +version = "0.1.0" +description = "$description" +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "pybotx==$pybotx_version", + "pybotx-fsm>=0.6.1,<0.7", + "dependency-injector>=4.48.1,<5", + "fastapi>=0.128.0,<0.129", + "opentelemetry-api>=1.38.0,<2", + "prometheus-client>=0.22.1,<0.23", + "pydantic-settings>=2.10.1,<3", + "redis>=5.0.0,<6", + "uvicorn>=0.40.0,<0.41", +] + +[dependency-groups] +dev = [ + "mypy>=1.19.1,<2", + "pytest>=9.0.2,<10", + "pytest-asyncio>=1.3.0,<2", + "ruff>=0.15.0,<0.16", +] + +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/$package_name"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.mypy] +python_version = "3.10" +strict = true +mypy_path = ["src"] +""" +) + + +PACKAGE_INIT_TEMPLATE = Template( + '''"""$display_name bot package."""\n''' +) + + +DOMAIN_INIT_TEMPLATE = Template( + '"""Domain layer: pure business models, rules, and contracts."""\n', +) + + +DOMAIN_PORTS_INIT_TEMPLATE = Template( + '"""Domain ports: contracts that infrastructure implements."""\n', +) + + +APPLICATION_INIT_TEMPLATE = Template( + '"""Application layer: use cases and orchestration."""\n', +) + + +APPLICATION_PORTS_INIT_TEMPLATE = Template( + '"""Application ports: use-case level contracts for outbound dependencies."""\n', +) + + +INFRASTRUCTURE_INIT_TEMPLATE = Template( + '"""Infrastructure layer: config, DI, repositories, and adapters."""\n', +) + + +INFRASTRUCTURE_REPOSITORIES_INIT_TEMPLATE = Template( + '"""Infrastructure repositories and adapters."""\n', +) + + +PRESENTATION_INIT_TEMPLATE = Template( + '"""Presentation layer: FastAPI, pybotx handlers, widgets, and FSM flows."""\n', +) + + +ARCHITECTURE_TEMPLATE = Template( + """# Architecture + +This scaffold uses a 4-layer clean architecture with `dependency-injector`. + +## Layers + +### `domain` + +- pure business concepts +- entities, value objects, domain services, protocols +- repository and gateway contracts can live in `domain/ports/` +- no imports from `application`, `infrastructure`, `presentation` +- no framework imports such as `pybotx`, `fastapi`, `redis`, `dependency_injector` + +### `application` + +- use cases and orchestration +- coordinates domain logic and calls ports/repositories +- may import `domain` +- must not import `presentation` or concrete `infrastructure` implementations + +### `infrastructure` + +- DI container +- configuration +- repositories and external adapters +- concrete repository implementations live in `infrastructure/repositories/` +- may depend on `application` and `domain` +- must not import `presentation` + +### `presentation` + +- FastAPI app +- `pybotx` bot wiring +- commands, widgets, FSM flows +- handlers stay thin: parse -> call application service -> return response/widget + +## Dependency Rule + +Allowed direction: + +`presentation -> application -> domain` + +`infrastructure -> application -> domain` + +`presentation` may depend on `infrastructure` only for composition roots like the DI container. + +Forbidden direction: + +- `domain -> application|infrastructure|presentation` +- `application -> infrastructure|presentation` +- `infrastructure -> presentation` + +## DI + +`dependency-injector` is the composition root. Concrete objects are assembled in: + +- `$container_module` + +Business code should not create its dependencies manually inside handlers. + +## Where To Put New Code + +### New command + +1. `presentation/commands/.py` +2. `application/services/.py` +3. add or reuse domain contracts in `domain/` or `domain/ports/` +4. add infrastructure adapters/repositories in `infrastructure/` +5. register the service in `src/$package_name/container.py` + +Or use: + +```bash +uv run pybotx create command --project-dir . +``` + +### New application service / use case + +1. `application/services/.py` +2. optional contract in `domain/ports/` +3. optional adapter in `infrastructure/` +4. register the provider in `src/$package_name/container.py` + +Or use: + +```bash +uv run pybotx create service --project-dir . +``` + +### New port + +1. `domain/ports/.py` +2. define the business-facing contract as `typing.Protocol` +3. implement the adapter later in `infrastructure/` + +Or use: + +```bash +uv run pybotx create port --project-dir . +``` + +### New repository / port pair + +1. `domain/ports/.py` +2. `infrastructure/repositories/.py` +3. register the provider in `src/$package_name/container.py` +4. inject the repository into application services through the generated provider + +Or use: + +```bash +uv run pybotx create repository --project-dir . +``` + +If the port already exists: + +```bash +uv run pybotx create repository --project-dir . --port +``` + +### New widget + +1. `presentation/commands/.py` +2. `application/services/.py` +3. shared widget runtime helpers live in `presentation/widget_support.py` + +Or use: + +```bash +uv run pybotx create widget --project-dir . --kind confirm +``` + +### New widget flow + +1. `presentation/commands/.py` +2. `application/services/_widget.py` +3. `application/services/_result.py` +4. widget callback/result wiring stays in the generated command module + +Or use: + +```bash +uv run pybotx create widget-flow --project-dir . --kind confirm +``` + +### New FSM flow + +1. `presentation/fsm/.py` +2. starter command in `presentation/commands/.py` +3. flow state storage implementation belongs to `infrastructure/` + +Or use: + +```bash +uv run pybotx create fsm-flow --project-dir . +``` + +## Practical Rules + +- keep `pybotx` imports inside `presentation` +- keep `fastapi` imports inside `presentation` +- keep `pydantic-settings`, `redis`, `dependency_injector` inside `infrastructure` +- application services return plain values; they should not send messages directly +- widgets and FSM flows are presentation concerns + +## Guardrails + +The scaffold includes: + +- `tests/test_architecture.py` to check layer import rules +- DI container in `src/$package_name/container.py` +- layered package layout from day one +- sample `GreetingProfileRepository` port with a DI-wired repository stub +""" +) + + +ARCHITECTURE_TEST_TEMPLATE = Template( + """from __future__ import annotations + +import ast +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] / "src" / "$package_name" +PACKAGE_PREFIX = "$package_name" + +FORBIDDEN_LAYER_IMPORTS = { + "domain": ( + f"{PACKAGE_PREFIX}.application", + f"{PACKAGE_PREFIX}.infrastructure", + f"{PACKAGE_PREFIX}.presentation", + ), + "application": ( + f"{PACKAGE_PREFIX}.infrastructure", + f"{PACKAGE_PREFIX}.presentation", + ), + "infrastructure": ( + f"{PACKAGE_PREFIX}.presentation", + ), +} + +FORBIDDEN_EXTERNAL_IMPORTS = { + "domain": ( + "pybotx", + "fastapi", + "redis", + "dependency_injector", + "pydantic_settings", + ), + "application": ( + "pybotx", + "fastapi", + "redis", + "dependency_injector", + "pydantic_settings", + ), +} + + +def _iter_imported_modules(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + imported_modules: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level != 0 or node.module is None: + continue + imported_modules.append(node.module) + return imported_modules + + +def _iter_layer_files(layer: str) -> list[Path]: + return sorted((PACKAGE_ROOT / layer).rglob("*.py")) + + +def test_layer_boundaries() -> None: + for layer, forbidden_prefixes in FORBIDDEN_LAYER_IMPORTS.items(): + for path in _iter_layer_files(layer): + imported_modules = _iter_imported_modules(path) + for module_name in imported_modules: + assert not module_name.startswith(forbidden_prefixes), ( + f"{path} imports outer layer module {module_name}" + ) + + +def test_framework_imports_are_kept_out_of_inner_layers() -> None: + for layer, forbidden_prefixes in FORBIDDEN_EXTERNAL_IMPORTS.items(): + for path in _iter_layer_files(layer): + imported_modules = _iter_imported_modules(path) + for module_name in imported_modules: + assert not module_name.startswith(forbidden_prefixes), ( + f"{path} imports framework module {module_name}" + ) +""" +) + + +CONFIG_TEMPLATE = Template( + """from functools import lru_cache +from typing import Literal +from uuid import UUID + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from pybotx import ( + BotAccountWithSecret, + BotCommandProcessingConfig, + DropOldestBotCommandOverloadStrategy, + RejectNewBotCommandOverloadStrategy, +) + + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + bot_id: UUID + bot_cts_url: AnyHttpUrl + bot_secret_key: str + + bot_display_name: str = "$display_name" + + bot_command_max_concurrency: int = 32 + bot_command_max_queue_size: int = 512 + bot_command_overload_action: Literal["reject_new", "drop_oldest"] = "reject_new" + + bot_enable_otel_tracing: bool = $enable_otel_tracing_python + bot_otel_tracer_name: str = "$package_name.bot" + + def build_bot_account(self) -> BotAccountWithSecret: + return BotAccountWithSecret( + id=self.bot_id, + cts_url=str(self.bot_cts_url), + secret_key=self.bot_secret_key, + ) + + def build_command_processing_config(self) -> BotCommandProcessingConfig: + overload_strategy = RejectNewBotCommandOverloadStrategy() + if self.bot_command_overload_action == "drop_oldest": + overload_strategy = DropOldestBotCommandOverloadStrategy() + + return BotCommandProcessingConfig( + max_concurrency=self.bot_command_max_concurrency, + max_queue_size=self.bot_command_max_queue_size, + overload_strategy=overload_strategy, + ) + + +@lru_cache +def get_settings() -> AppSettings: + return AppSettings() +""" +) + + +CONFIG_FSM_TEMPLATE = Template( + """from functools import lru_cache +from typing import Literal +from uuid import UUID + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from pybotx import ( + BotAccountWithSecret, + BotCommandProcessingConfig, + DropOldestBotCommandOverloadStrategy, + RejectNewBotCommandOverloadStrategy, +) + + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + bot_id: UUID + bot_cts_url: AnyHttpUrl + bot_secret_key: str + + bot_display_name: str = "$display_name" + + bot_command_max_concurrency: int = 32 + bot_command_max_queue_size: int = 512 + bot_command_overload_action: Literal["reject_new", "drop_oldest"] = "reject_new" + + bot_enable_otel_tracing: bool = $enable_otel_tracing_python + bot_otel_tracer_name: str = "$package_name.bot" + + bot_fsm_redis_url: str = "redis://localhost:6379/0" + bot_fsm_state_ttl_seconds: int = 900 + + def build_bot_account(self) -> BotAccountWithSecret: + return BotAccountWithSecret( + id=self.bot_id, + cts_url=str(self.bot_cts_url), + secret_key=self.bot_secret_key, + ) + + def build_command_processing_config(self) -> BotCommandProcessingConfig: + overload_strategy = RejectNewBotCommandOverloadStrategy() + if self.bot_command_overload_action == "drop_oldest": + overload_strategy = DropOldestBotCommandOverloadStrategy() + + return BotCommandProcessingConfig( + max_concurrency=self.bot_command_max_concurrency, + max_queue_size=self.bot_command_max_queue_size, + overload_strategy=overload_strategy, + ) + + +@lru_cache +def get_settings() -> AppSettings: + return AppSettings() +""" +) + + +CONTAINER_TEMPLATE = Template( + """from dependency_injector import containers, providers +from prometheus_client import CollectorRegistry + +from $bot_module import build_bot +from $config_module import get_settings +from $static_greeting_profile_repository_module import ( + StaticGreetingProfileRepository, +) +# pybotx-scaffold repository imports start +# pybotx-scaffold repository imports end +from $services_module_prefix.greeting import GreetingService +# pybotx-scaffold service imports start +# pybotx-scaffold service imports end + + +class ApplicationContainer(containers.DeclarativeContainer): + settings = providers.Singleton(get_settings) + prometheus_registry = providers.Singleton(CollectorRegistry) + + greeting_profile_repository = providers.Singleton( + StaticGreetingProfileRepository, + display_name=settings.provided.bot_display_name, + ) + # pybotx-scaffold repository providers start + # pybotx-scaffold repository providers end + greeting_service = providers.Factory( + GreetingService, + greeting_profile_repository=greeting_profile_repository, + ) + # pybotx-scaffold service providers start + # pybotx-scaffold service providers end + + bot = providers.Singleton( + build_bot, + settings=settings, + registry=prometheus_registry, + ) +""" +) + + +CONTAINER_FSM_TEMPLATE = Template( + """from dependency_injector import containers, providers +from prometheus_client import CollectorRegistry +from redis.asyncio import from_url + +from $bot_module import build_bot +from $config_module import get_settings +from $fsm_state_repo_module import RedisFSMStateRepo +from $static_greeting_profile_repository_module import ( + StaticGreetingProfileRepository, +) +# pybotx-scaffold repository imports start +# pybotx-scaffold repository imports end +from $services_module_prefix.auth import AuthService +from $services_module_prefix.greeting import GreetingService +# pybotx-scaffold service imports start +# pybotx-scaffold service imports end + + +class ApplicationContainer(containers.DeclarativeContainer): + settings = providers.Singleton(get_settings) + prometheus_registry = providers.Singleton(CollectorRegistry) + + greeting_profile_repository = providers.Singleton( + StaticGreetingProfileRepository, + display_name=settings.provided.bot_display_name, + ) + # pybotx-scaffold repository providers start + # pybotx-scaffold repository providers end + greeting_service = providers.Factory( + GreetingService, + greeting_profile_repository=greeting_profile_repository, + ) + auth_service = providers.Factory( + AuthService, + users={ + "demo@example.com": "password123", + "admin@example.com": "admin123", + }, + ) + + fsm_redis_client = providers.Singleton( + from_url, + settings.provided.bot_fsm_redis_url, + encoding="utf-8", + decode_responses=True, + ) + fsm_state_repo = providers.Singleton( + RedisFSMStateRepo, + redis=fsm_redis_client, + default_expire_seconds=settings.provided.bot_fsm_state_ttl_seconds, + ) + # pybotx-scaffold service providers start + # pybotx-scaffold service providers end + + bot = providers.Singleton( + build_bot, + settings=settings, + registry=prometheus_registry, + ) +""" +) + + +SERVICES_INIT_TEMPLATE = Template('"""Application services."""\n') + + +WIDGET_SUPPORT_TEMPLATE = Template( + """from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar, cast + +from pybotx import Bot, IncomingMessage +from pybotx.widgets import ( + WidgetContext, + WidgetFactory, + WidgetRunnerConfig, + build_production_widget_runner_preset, +) +from pybotx.widgets.base import Widget +from pybotx.widgets.runner import RunnerAfterHook, RunnerBeforeHook + +if TYPE_CHECKING: + from $container_module import ApplicationContainer + +W = TypeVar("W", bound=Widget) + + +def get_container(bot: Bot) -> "ApplicationContainer": + return cast("ApplicationContainer", bot.state.container) + + +def build_widget_factory( + message: IncomingMessage, + bot: Bot, + *, + command: str, +) -> WidgetFactory: + return WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command=command, + ), + ) + + +def build_widget_runner_config( + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, +) -> WidgetRunnerConfig: + return build_production_widget_runner_preset( + before=before, + after=after, + ).as_config() +""" +) + + +GREETING_PROFILE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class GreetingProfile: + display_name: str + help_commands: tuple[str, ...] = ( + "/start - Show greeting", + "/help - List available commands", + ) +""" +) + + +GREETING_PROFILE_REPOSITORY_TEMPLATE = Template( + """from typing import Protocol + +from $greeting_profile_module import GreetingProfile + + +class GreetingProfileRepository(Protocol): + def get_profile(self) -> GreetingProfile: + ... +""" +) + + +STATIC_GREETING_PROFILE_REPOSITORY_TEMPLATE = Template( + """from dataclasses import dataclass + +from $greeting_profile_module import GreetingProfile + + +@dataclass(frozen=True, slots=True) +class StaticGreetingProfileRepository: + display_name: str + help_commands: tuple[str, ...] = ( + "/start - Show greeting", + "/help - List available commands", + ) + + def get_profile(self) -> GreetingProfile: + return GreetingProfile( + display_name=self.display_name, + help_commands=self.help_commands, + ) +""" +) + + +REPOSITORY_PORT_TEMPLATE = Template( + """from typing import Protocol + + +class $port_class_name(Protocol): + \"\"\"Domain repository port for `$repository_slug`. + + Add domain-specific methods here, then implement them in the infrastructure + adapter generated alongside this port. + \"\"\" + + ... +""" +) + + +PORT_TEMPLATE = Template( + """from typing import Protocol + + +class $port_class_name(Protocol): + \"\"\"Domain port for `$port_slug`. + + Add business-facing methods here. Implement this contract in the + infrastructure layer and inject it into application services through DI. + \"\"\" + + ... +""" +) + + +REPOSITORY_IMPLEMENTATION_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(slots=True) +class $implementation_class_name: + \"\"\"Infrastructure repository stub for `$repository_slug`. + + Replace this class with a concrete Redis/Postgres/HTTP implementation after + defining the domain-specific contract in `domain/ports/` and wiring it into + the `$port_class_name` port. + \"\"\" + + ... +""" +) + + +GREETING_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + +from $greeting_profile_repository_module import GreetingProfileRepository + + +@dataclass(frozen=True, slots=True) +class GreetingService: + greeting_profile_repository: GreetingProfileRepository + + def get_help_commands(self) -> tuple[str, ...]: + return self.greeting_profile_repository.get_profile().help_commands + + def build_start_message(self) -> str: + profile = self.greeting_profile_repository.get_profile() + return ( + f"Hello from {profile.display_name}.\\n\\n" + "Use /help to see available commands." + ) + + def build_help_message(self) -> str: + return "\\n".join(self.get_help_commands()) +""" +) + + +AUTH_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class AuthService: + users: dict[str, str] = field(default_factory=dict) + + def is_known_email(self, email: str) -> bool: + return email in self.users + + def is_valid_password(self, email: str, password: str) -> bool: + return self.users.get(email) == password +""" +) + + +COMMANDS_INIT_TEMPLATE = Template( + """from $commands_module_prefix.common import collector as common_collector +# pybotx-scaffold command imports start +# pybotx-scaffold command imports end + +collectors = [ + common_collector, + # pybotx-scaffold command collectors start + # pybotx-scaffold command collectors end +] +""" +) + + +COMMANDS_INIT_FSM_TEMPLATE = Template( + """from $commands_module_prefix.common import collector as common_collector +from $fsm_module_prefix.login import fsm as login_fsm +# pybotx-scaffold command imports start +# pybotx-scaffold command imports end + +collectors = [ + common_collector, + login_fsm, + # pybotx-scaffold command collectors start + # pybotx-scaffold command collectors end +] +""" +) + + +COMMON_COMMANDS_TEMPLATE = Template( + """from typing import TYPE_CHECKING, cast + +from pybotx import Bot, HandlerCollector, IncomingMessage + +if TYPE_CHECKING: + from $container_module import ApplicationContainer + + +collector = HandlerCollector() + + +def _get_container(bot: Bot) -> "ApplicationContainer": + return cast("ApplicationContainer", bot.state.container) + + +@collector.command("/start", description="Show greeting") +async def start_handler(_: IncomingMessage, bot: Bot) -> None: + greeting_service = _get_container(bot).greeting_service() + await bot.answer_message(greeting_service.build_start_message()) + + +@collector.command("/help", description="List available commands") +async def help_handler(_: IncomingMessage, bot: Bot) -> None: + greeting_service = _get_container(bot).greeting_service() + await bot.answer_message(greeting_service.build_help_message()) +""" +) + + +COMMON_COMMANDS_FSM_TEMPLATE = Template( + """from typing import TYPE_CHECKING, cast + +from pybotx import Bot, HandlerCollector, IncomingMessage + +from $fsm_module_prefix.login import LoginStates + +if TYPE_CHECKING: + from $container_module import ApplicationContainer + + +collector = HandlerCollector() + + +def _get_container(bot: Bot) -> "ApplicationContainer": + return cast("ApplicationContainer", bot.state.container) + + +@collector.command("/start", description="Show greeting") +async def start_handler(_: IncomingMessage, bot: Bot) -> None: + greeting_service = _get_container(bot).greeting_service() + await bot.answer_message(greeting_service.build_start_message()) + + +@collector.command("/help", description="List available commands") +async def help_handler(_: IncomingMessage, bot: Bot) -> None: + greeting_service = _get_container(bot).greeting_service() + await bot.answer_message( + "\\n".join( + [ + *greeting_service.get_help_commands(), + "/login - Start demo FSM login flow", + "/cancel - Drop the current FSM state", + ], + ), + ) + + +@collector.command("/login", description="Start demo FSM login flow") +async def login_handler(message: IncomingMessage, bot: Bot) -> None: + await message.state.fsm.change_state(LoginStates.ENTER_EMAIL) + await bot.answer_message("Enter your email.") + + +@collector.command("/cancel", description="Drop active FSM state") +async def cancel_handler(message: IncomingMessage, bot: Bot) -> None: + state = await message.state.fsm.get_state() + if state is None: + await bot.answer_message("No active login flow.") + return + + await message.state.fsm.drop_state() + await bot.answer_message("Login flow cancelled.") +""" +) + + +BOT_TEMPLATE = Template( + """from prometheus_client import CollectorRegistry +from pybotx import ( + Bot, + build_production_bot_preset, + build_production_observability_preset, +) + +from $commands_module_prefix import collectors +from $config_module import AppSettings + + +def build_bot(*, settings: AppSettings, registry: CollectorRegistry) -> Bot: + production_preset = build_production_bot_preset( + observability_preset=build_production_observability_preset( + registry=registry, + enable_open_telemetry_tracing=settings.bot_enable_otel_tracing, + tracer_name=settings.bot_otel_tracer_name, + ), + ) + bot_kwargs = production_preset.as_bot_kwargs() + return Bot( + collectors=collectors, + bot_accounts=[settings.build_bot_account()], + command_processing_config=settings.build_command_processing_config(), + **bot_kwargs, + ) +""" +) + + +BOT_FSM_TEMPLATE = Template( + """from prometheus_client import CollectorRegistry +from pybotx import ( + Bot, + build_production_bot_preset, + build_production_observability_preset, +) +from pybotx_fsm import FSMMiddleware + +from $commands_module_prefix import collectors +from $config_module import AppSettings +from $fsm_module_prefix.login import fsm as login_fsm +# pybotx-scaffold fsm imports start +# pybotx-scaffold fsm imports end + + +def build_bot(*, settings: AppSettings, registry: CollectorRegistry) -> Bot: + production_preset = build_production_bot_preset( + observability_preset=build_production_observability_preset( + registry=registry, + enable_open_telemetry_tracing=settings.bot_enable_otel_tracing, + tracer_name=settings.bot_otel_tracer_name, + ), + ) + bot_kwargs = production_preset.as_bot_kwargs() + return Bot( + collectors=collectors, + bot_accounts=[settings.build_bot_account()], + command_processing_config=settings.build_command_processing_config(), + middlewares=[ + FSMMiddleware( + [ + login_fsm, + # pybotx-scaffold fsm collectors start + # pybotx-scaffold fsm collectors end + ], + state_repo_key="fsm_state_repo", + ), + ], + **bot_kwargs, + ) +""" +) + + +PRESENTATION_API_TEMPLATE = Template( + """from fastapi import FastAPI +from pybotx import FastAPIBotAppConfig, create_fastapi_bot_app + +from $container_module import ApplicationContainer + + +def create_app() -> FastAPI: + container = ApplicationContainer() + settings = container.settings() + bot = container.bot() + bot.state.container = container + return create_fastapi_bot_app( + bot=bot, + title=settings.bot_display_name, + openapi_url=None, + docs_url=None, + redoc_url=None, + config=FastAPIBotAppConfig( + metrics_path="/metrics", + ), + metrics_registry=container.prometheus_registry(), + app_state={"container": container}, + ) + + +app = create_app() +""" +) + + +MAIN_TEMPLATE = Template( + """from fastapi import FastAPI + +from $api_module import create_app + + +app: FastAPI = create_app() +""" +) + + +MAIN_FSM_TEMPLATE = Template( + """from fastapi import FastAPI + +from $api_module import create_app + + +app: FastAPI = create_app() +""" +) + + +PRESENTATION_API_FSM_TEMPLATE = Template( + """from fastapi import FastAPI +from pybotx import ( + FastAPIBotAppConfig, + ReadinessCheck, + ReadinessCheckResult, + create_fastapi_bot_app, +) + +from $container_module import ApplicationContainer + + +async def _check_fsm_redis(container: ApplicationContainer) -> ReadinessCheckResult: + redis = container.fsm_redis_client() + await redis.ping() + return ReadinessCheckResult(status="ok") + + +def create_app() -> FastAPI: + container = ApplicationContainer() + settings = container.settings() + bot = container.bot() + bot.state.container = container + bot.state.fsm_state_repo = container.fsm_state_repo() + + app = create_fastapi_bot_app( + bot=bot, + title=settings.bot_display_name, + openapi_url=None, + docs_url=None, + redoc_url=None, + config=FastAPIBotAppConfig( + metrics_path="/metrics", + ), + metrics_registry=container.prometheus_registry(), + app_state={"container": container}, + ) + app.state.healthcheck.add_readiness_check( + ReadinessCheck( + name="fsm_redis", + check=lambda: _check_fsm_redis(container), + critical=True, + timeout_seconds=0.5, + ), + ) + + async def shutdown_redis() -> None: + await container.fsm_redis_client().aclose() + + app.add_event_handler("shutdown", shutdown_redis) + return app +""" +) + + +FSM_INIT_TEMPLATE = Template('"""FSM flows."""\n') + + +FSM_LOGIN_TEMPLATE = Template( + """from enum import Enum, auto +from typing import TYPE_CHECKING, cast + +from pybotx import Bot, IncomingMessage +from pybotx_fsm import FSMCollector + +if TYPE_CHECKING: + from $container_module import ApplicationContainer + + +class LoginStates(Enum): + ENTER_EMAIL = auto() + ENTER_PASSWORD = auto() + + +fsm = FSMCollector(LoginStates) + + +def _get_container(bot: Bot) -> "ApplicationContainer": + return cast("ApplicationContainer", bot.state.container) + + +@fsm.on(LoginStates.ENTER_EMAIL) +async def enter_email(message: IncomingMessage, bot: Bot) -> None: + auth_service = _get_container(bot).auth_service() + email = message.body.strip() + + if not auth_service.is_known_email(email): + await bot.answer_message("Unknown email, try again.") + return + + await message.state.fsm.change_state(LoginStates.ENTER_PASSWORD, email=email) + await bot.answer_message("Enter your password.") + + +@fsm.on(LoginStates.ENTER_PASSWORD) +async def enter_password(message: IncomingMessage, bot: Bot) -> None: + auth_service = _get_container(bot).auth_service() + email = message.state.fsm_storage.email + password = message.body + + if not auth_service.is_valid_password(email, password): + await bot.answer_message("Wrong password, try again.") + return + + await message.state.fsm.drop_state() + await bot.answer_message(f"Logged in as {email}.") +""" +) + + +FSM_STATE_REPO_TEMPLATE = Template( + """from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import importlib +import json +from types import SimpleNamespace +from typing import Any + +from pybotx_fsm.fsm import FSMStateData +from redis.asyncio import Redis + + +@dataclass(frozen=True, slots=True) +class RedisFSMStateRepo: + redis: Redis + default_expire_seconds: int = 900 + key_prefix: str = "pybotx:fsm" + + def __post_init__(self) -> None: + if self.default_expire_seconds <= 0: + raise ValueError("FSM state TTL should be greater than 0") + if not self.key_prefix: + raise ValueError("FSM state key prefix should not be empty") + + async def get(self, key: Any, default: Any = None) -> Any: + payload = await self.redis.get(self._build_redis_key(key)) + if payload is None: + return default + + parsed_payload = json.loads(payload) + enum_cls = self._load_enum_class( + module_name=parsed_payload["state_module"], + qualname=parsed_payload["state_qualname"], + ) + state = enum_cls[parsed_payload["state_name"]] + storage = SimpleNamespace(**parsed_payload["storage"]) + return FSMStateData(state=state, storage=storage) + + async def set( + self, + key: Any, + value: Any, + expire: int | None = None, + ) -> None: + if not isinstance(value, FSMStateData): + raise TypeError("RedisFSMStateRepo expects FSMStateData values") + if not isinstance(value.state, Enum): + raise TypeError("FSM state should be an Enum member") + + storage = vars(value.storage) + payload = json.dumps( + { + "state_module": value.state.__class__.__module__, + "state_qualname": value.state.__class__.__qualname__, + "state_name": value.state.name, + "storage": storage, + }, + ensure_ascii=True, + sort_keys=True, + ) + await self.redis.set( + self._build_redis_key(key), + payload, + ex=expire or self.default_expire_seconds, + ) + + async def delete(self, key: Any) -> None: + await self.redis.delete(self._build_redis_key(key)) + + def _build_redis_key(self, key: Any) -> str: + return f"{self.key_prefix}:{key}" + + def _load_enum_class(self, *, module_name: str, qualname: str) -> type[Enum]: + module = importlib.import_module(module_name) + target: Any = module + for part in qualname.split("."): + target = getattr(target, part) + + if not isinstance(target, type) or not issubclass(target, Enum): + raise TypeError( + f"Stored FSM state class `{module_name}:{qualname}` is not an Enum", + ) + + return target +""" +) + + +TESTS_CONFTEST_TEMPLATE = Template( + """from collections.abc import Callable +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +from dependency_injector import providers +import pytest +from pybotx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + IncomingMessage, + UserDevice, + UserSender, +) + +from $commands_module_prefix import collectors +from $container_module import ApplicationContainer + + +@pytest.fixture +def bot_account() -> BotAccountWithSecret: + return BotAccountWithSecret( + id=UUID("123e4567-e89b-12d3-a456-426655440000"), + cts_url="https://cts.example.com", + secret_key="test-secret", + ) + + +@pytest.fixture +def container() -> ApplicationContainer: + built_container = ApplicationContainer() + built_container.settings.override( + providers.Object(SimpleNamespace(bot_display_name="Test Bot")), + ) + return built_container + + +@pytest.fixture +def bot( + bot_account: BotAccountWithSecret, + container: ApplicationContainer, +) -> Bot: + built_bot = Bot( + collectors=collectors, + bot_accounts=[bot_account], + ) + built_bot.answer_message = AsyncMock(return_value=uuid4()) # type: ignore[method-assign] + built_bot.send = AsyncMock(return_value=uuid4()) # type: ignore[method-assign] + built_bot.edit_message = AsyncMock() # type: ignore[method-assign] + built_bot.state.container = container + return built_bot + + +@pytest.fixture +def incoming_message_factory( + bot_account: BotAccountWithSecret, +) -> Callable[..., IncomingMessage]: + def factory(*, body: str = "/start") -> IncomingMessage: + return IncomingMessage( + bot=BotAccount(id=bot_account.id, host=bot_account.host), + sync_id=uuid4(), + source_sync_id=None, + body=body, + data={}, + metadata={}, + sender=UserSender( + huid=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + udid=None, + ad_login=None, + ad_domain=None, + username="Test User", + is_chat_admin=True, + is_chat_creator=True, + device=UserDevice( + manufacturer=None, + device_name=None, + os=None, + pushes=None, + timezone=None, + permissions=None, + platform=None, + platform_package_id=None, + app_version=None, + locale=None, + ), + ), + chat=Chat( + id=uuid4(), + type=ChatTypes.PERSONAL_CHAT, + ), + raw_command=None, + ) + + return factory +""" +) + + +TESTS_CONFTEST_FSM_TEMPLATE = Template( + """from collections.abc import Callable +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +from dependency_injector import providers +import pytest +from pybotx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + IncomingMessage, + UserDevice, + UserSender, +) + +from pybotx_fsm import FSMMiddleware + +from $commands_module_prefix import collectors +from $container_module import ApplicationContainer +from $fsm_module_prefix.login import fsm as login_fsm +# pybotx-scaffold fsm imports start +# pybotx-scaffold fsm imports end + + +class InMemoryFSMStateRepo: + def __init__(self) -> None: + self._storage: dict[object, object] = {} + + async def get(self, key: object, default: object = None) -> object: + return self._storage.get(key, default) + + async def set(self, key: object, value: object, expire: int | None = None) -> None: + self._storage[key] = value + + async def delete(self, key: object) -> None: + self._storage.pop(key, None) + + +@pytest.fixture +def bot_account() -> BotAccountWithSecret: + return BotAccountWithSecret( + id=UUID("123e4567-e89b-12d3-a456-426655440000"), + cts_url="https://cts.example.com", + secret_key="test-secret", + ) + + +@pytest.fixture +def container() -> ApplicationContainer: + built_container = ApplicationContainer() + built_container.settings.override( + providers.Object( + SimpleNamespace( + bot_display_name="Test Bot", + bot_fsm_redis_url="redis://localhost:6379/0", + bot_fsm_state_ttl_seconds=900, + ), + ), + ) + return built_container + + +@pytest.fixture +def bot( + bot_account: BotAccountWithSecret, + container: ApplicationContainer, +) -> Bot: + built_bot = Bot( + collectors=collectors, + bot_accounts=[bot_account], + middlewares=[ + FSMMiddleware( + [ + login_fsm, + # pybotx-scaffold fsm collectors start + # pybotx-scaffold fsm collectors end + ], + state_repo_key="fsm_state_repo", + ), + ], + ) + built_bot.answer_message = AsyncMock(return_value=uuid4()) # type: ignore[method-assign] + built_bot.send = AsyncMock(return_value=uuid4()) # type: ignore[method-assign] + built_bot.edit_message = AsyncMock() # type: ignore[method-assign] + built_bot.state.container = container + built_bot.state.fsm_state_repo = InMemoryFSMStateRepo() + return built_bot + + +@pytest.fixture +def incoming_message_factory( + bot_account: BotAccountWithSecret, +) -> Callable[..., IncomingMessage]: + stable_chat_id = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + stable_user_huid = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + + def factory(*, body: str = "/start") -> IncomingMessage: + return IncomingMessage( + bot=BotAccount(id=bot_account.id, host=bot_account.host), + sync_id=uuid4(), + source_sync_id=None, + body=body, + data={}, + metadata={}, + sender=UserSender( + huid=stable_user_huid, + udid=None, + ad_login=None, + ad_domain=None, + username="Test User", + is_chat_admin=True, + is_chat_creator=True, + device=UserDevice( + manufacturer=None, + device_name=None, + os=None, + pushes=None, + timezone=None, + permissions=None, + platform=None, + platform_package_id=None, + app_version=None, + locale=None, + ), + ), + chat=Chat( + id=stable_chat_id, + type=ChatTypes.PERSONAL_CHAT, + ), + raw_command=None, + ) + + return factory +""" +) + + +TEST_START_COMMAND_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage + + +async def test_start_handler( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body="/start") + + await bot.async_execute_bot_command(message) + + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + "Hello from Test Bot.\\n\\nUse /help to see available commands.", + ) + + +async def test_help_handler( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body="/help") + + await bot.async_execute_bot_command(message) + + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + "/start - Show greeting\\n/help - List available commands", + ) +""" +) + + +TEST_START_COMMAND_FSM_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage + + +async def test_start_handler( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body="/start")) + + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + "Hello from Test Bot.\\n\\nUse /help to see available commands.", + ) + + +async def test_login_fsm_flow( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body="/login")) + bot.answer_message.assert_awaited_once_with("Enter your email.") # type: ignore[attr-defined] + + bot.answer_message.reset_mock() # type: ignore[attr-defined] + await bot.async_execute_bot_command( + incoming_message_factory(body="demo@example.com"), + ) + bot.answer_message.assert_awaited_once_with("Enter your password.") # type: ignore[attr-defined] + + bot.answer_message.reset_mock() # type: ignore[attr-defined] + await bot.async_execute_bot_command(incoming_message_factory(body="password123")) + bot.answer_message.assert_awaited_once_with("Logged in as demo@example.com.") # type: ignore[attr-defined] +""" +) + + +COMMAND_MODULE_TEMPLATE = Template( + """from typing import TYPE_CHECKING, cast + +from pybotx import Bot, HandlerCollector, IncomingMessage + +if TYPE_CHECKING: + from $container_module import ApplicationContainer + + +collector = HandlerCollector() + + +def _get_container(bot: Bot) -> "ApplicationContainer": + return cast("ApplicationContainer", bot.state.container) + + +@collector.command($command_literal, description=$description_literal) +async def $handler_name(_: IncomingMessage, bot: Bot) -> None: + command_service = _get_container(bot).$service_provider_name() + await bot.answer_message(command_service.handle()) +""" +) + + +SERVICE_MODULE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def handle(self) -> str: + return $response_text_literal +""" +) + + +COMMAND_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage + + +async def test_${module_name}_handler( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + + await bot.async_execute_bot_command(message) + + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + $response_text_literal, + ) +""" +) + + +APPLICATION_SERVICE_TEST_TEMPLATE = Template( + """from $services_module_prefix.$module_name import $service_class_name + + +def test_${module_name}_service() -> None: + service = $service_class_name() + + assert service.handle() == $response_text_literal +""" +) + + +REPOSITORY_TEST_TEMPLATE = Template( + """from $container_module import ApplicationContainer +from $repository_module import $implementation_class_name + + +def test_${module_name}_repository_provider() -> None: + container = ApplicationContainer() + + repository = container.$repository_provider_name() + + assert isinstance(repository, $implementation_class_name) +""" +) + + +WIDGET_CONFIRM_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import ConfirmWidget, on_action, widget_command +from pybotx.widgets.confirm import CANCEL_ACTION, CONFIRM_ACTION + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_action_result(CONFIRM_ACTION) + ), + CANCEL_ACTION: lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_action_result(CANCEL_ACTION) + ), + }, + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.confirm(label=widget_service.build_label()) +""" +) + + +WIDGET_CONFIRM_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_action_result(self, action: str) -> str: + if action == "confirm": + return $primary_result_literal + if action == "cancel": + return $secondary_result_literal + + return f"Unhandled widget action: {action}" +""" +) + + +WIDGET_CONFIRM_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.confirm import CONFIRM_ACTION, CONFIRM_ACTION_KEY + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_action( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {CONFIRM_ACTION_KEY: CONFIRM_ACTION} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_APPROVAL_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import ApprovalWidget, on_action, widget_command +from pybotx.widgets.approval import APPROVE_ACTION, COMMENT_ACTION, REJECT_ACTION + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_action( + lambda widget: ApprovalWidget.get_action(widget.message), + { + APPROVE_ACTION: lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_action_result(APPROVE_ACTION) + ), + REJECT_ACTION: lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_action_result(REJECT_ACTION) + ), + COMMENT_ACTION: lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_action_result(COMMENT_ACTION) + ), + }, + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> ApprovalWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.approval(label=widget_service.build_label()) +""" +) + + +WIDGET_APPROVAL_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_action_result(self, action: str) -> str: + if action == "approve": + return $primary_result_literal + if action == "reject": + return $secondary_result_literal + if action == "comment": + return $tertiary_result_literal + + return f"Unhandled widget action: {action}" +""" +) + + +WIDGET_APPROVAL_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.approval import APPROVAL_ACTION_KEY, APPROVE_ACTION + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_action( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {APPROVAL_ACTION_KEY: APPROVE_ACTION} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import SelectWidget, widget_command, on_data_key +from pybotx.widgets.select import SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_selection_result(SelectWidget.get_value(widget.message)) + ), + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> SelectWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.select( + label=widget_service.build_label(), + options=widget_service.build_options(), + ) +""" +) + + +WIDGET_SELECT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_options(self) -> list[str]: + return [ + "dev", + "staging", + "prod", + ] + + def build_selection_result(self, value: str) -> str: + return f"Selected value: {value}" +""" +) + + +WIDGET_SELECT_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.select import SELECT_VALUE_KEY + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_selection( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {SELECT_VALUE_KEY: "prod"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == "Selected value: prod" # type: ignore[attr-defined] +""" +) + + +WIDGET_SEARCH_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import SearchSelectWidget, on_data_key, widget_command +from pybotx.widgets.select import SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: ( + get_container(widget.bot) + .$service_provider_name() + .build_selection_result(SearchSelectWidget.get_value(widget.message)) + ), + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> SearchSelectWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.search_select( + label=widget_service.build_label(), + options=widget_service.build_options(), + ) +""" +) + + +WIDGET_SEARCH_SELECT_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.select import SELECT_VALUE_KEY + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_selection( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {SELECT_VALUE_KEY: "prod"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == "Selected value: prod" # type: ignore[attr-defined] +""" +) + + +WIDGET_MULTI_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import MultiSelectWidget, RunnerHookResult, on_data_key, widget_command +from pybotx.widgets.multi_select import MULTI_SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +def _resolve_multi_select_result(widget: MultiSelectWidget) -> RunnerHookResult | None: + widget_service = get_container(widget.bot).$service_provider_name() + selected_values = widget.selected_values + if not widget_service.is_selection_complete(selected_values): + return None + + return RunnerHookResult( + result=widget_service.build_selection_result(selected_values), + should_display=False, + ) + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key(MULTI_SELECT_VALUE_KEY, _resolve_multi_select_result), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> MultiSelectWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.multi_select( + label=widget_service.build_label(), + options=widget_service.build_options(), + max_selected=widget_service.max_selected(), + ) +""" +) + + +WIDGET_MULTI_SELECT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_options(self) -> list[str]: + return [ + "dev", + "staging", + "prod", + ] + + def max_selected(self) -> int: + return 2 + + def is_selection_complete(self, values: list[str]) -> bool: + return len(values) >= self.max_selected() + + def build_selection_result(self, values: list[str]) -> str: + return f"Selected values: {', '.join(values)}" +""" +) + + +WIDGET_MULTI_SELECT_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.multi_select import MULTI_SELECTED_VALUES_KEY, MULTI_SELECT_VALUE_KEY + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_completed_selection( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.metadata = { + MULTI_SELECTED_VALUES_KEY: ["dev"], + } + message.data = {MULTI_SELECT_VALUE_KEY: "prod"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_DATE_RANGE_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import DateRangeWidget, RunnerHookResult, on_data_key, widget_command +from pybotx.widgets.date_range import DATE_RANGE_SELECTED_DATE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +def _resolve_date_range_result(widget: DateRangeWidget) -> RunnerHookResult | None: + if widget.start is None or widget.end is None: + return None + + widget_service = get_container(widget.bot).$service_provider_name() + return RunnerHookResult( + result=widget_service.build_selection_result(widget.start, widget.end), + should_display=False, + ) + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key(DATE_RANGE_SELECTED_DATE_KEY, _resolve_date_range_result), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> DateRangeWidget: + widget_service = get_container(bot).$service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.date_range(label=widget_service.build_label()) +""" +) + + +WIDGET_DATE_RANGE_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True, slots=True) +class $service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_selection_result(self, start_date: date, end_date: date) -> str: + return f"Selected period: {start_date.isoformat()} - {end_date.isoformat()}" +""" +) + + +WIDGET_DATE_RANGE_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.date_range import ( + DATE_RANGE_CURSOR_KEY, + DATE_RANGE_SELECTED_DATE_KEY, + DATE_RANGE_START_KEY, +) + + +async def test_${module_name}_widget_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $display_body_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_handles_completed_range( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.metadata = { + DATE_RANGE_START_KEY: "2026-03-01", + DATE_RANGE_CURSOR_KEY: "end", + } + message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-03-08"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_FLOW_LABEL_WIDGET_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $widget_service_class_name: + def build_label(self) -> str: + return $label_literal +""" +) + + +WIDGET_FLOW_OPTIONS_WIDGET_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $widget_service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_options(self) -> list[str]: + return [ + "dev", + "staging", + "prod", + ] +""" +) + + +WIDGET_FLOW_MULTI_SELECT_WIDGET_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $widget_service_class_name: + def build_label(self) -> str: + return $label_literal + + def build_options(self) -> list[str]: + return [ + "dev", + "staging", + "prod", + ] + + def max_selected(self) -> int: + return 2 +""" +) + + +WIDGET_FLOW_CONFIRM_RESULT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $result_service_class_name: + def handle_action(self, action: str) -> str: + if action == "confirm": + return $primary_result_literal + if action == "cancel": + return $secondary_result_literal + + return f"Unhandled widget action: {action}" +""" +) + + +WIDGET_FLOW_APPROVAL_RESULT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $result_service_class_name: + def handle_action(self, action: str) -> str: + if action == "approve": + return $primary_result_literal + if action == "reject": + return $secondary_result_literal + if action == "comment": + return $tertiary_result_literal + + return f"Unhandled widget action: {action}" +""" +) + + +WIDGET_FLOW_SELECTION_RESULT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $result_service_class_name: + def handle_selection(self, value: str) -> str: + return f"Selected value: {value}" +""" +) + + +WIDGET_FLOW_MULTI_SELECTION_RESULT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class $result_service_class_name: + def handle_selection(self, values: list[str]) -> str: + return f"Selected values: {', '.join(values)}" +""" +) + + +WIDGET_FLOW_DATE_RANGE_RESULT_SERVICE_TEMPLATE = Template( + """from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True, slots=True) +class $result_service_class_name: + def handle_selection(self, start_date: date, end_date: date) -> str: + return f"Selected period: {start_date.isoformat()} - {end_date.isoformat()}" +""" +) + + +WIDGET_FLOW_CONFIRM_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import ConfirmWidget, on_action, widget_command +from pybotx.widgets.confirm import CANCEL_ACTION, CONFIRM_ACTION + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_action( + lambda widget: ConfirmWidget.get_action(widget.message), + { + CONFIRM_ACTION: lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_action(CONFIRM_ACTION) + ), + CANCEL_ACTION: lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_action(CANCEL_ACTION) + ), + }, + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> ConfirmWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.confirm(label=widget_service.build_label()) +""" +) + + +WIDGET_FLOW_APPROVAL_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import ApprovalWidget, on_action, widget_command +from pybotx.widgets.approval import APPROVE_ACTION, COMMENT_ACTION, REJECT_ACTION + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_action( + lambda widget: ApprovalWidget.get_action(widget.message), + { + APPROVE_ACTION: lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_action(APPROVE_ACTION) + ), + REJECT_ACTION: lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_action(REJECT_ACTION) + ), + COMMENT_ACTION: lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_action(COMMENT_ACTION) + ), + }, + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> ApprovalWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.approval(label=widget_service.build_label()) +""" +) + + +WIDGET_FLOW_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import SelectWidget, on_data_key, widget_command +from pybotx.widgets.select import SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_selection(SelectWidget.get_value(widget.message)) + ), + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> SelectWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.select( + label=widget_service.build_label(), + options=widget_service.build_options(), + ) +""" +) + + +WIDGET_FLOW_SEARCH_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import SearchSelectWidget, on_data_key, widget_command +from pybotx.widgets.select import SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key( + SELECT_VALUE_KEY, + lambda widget: ( + get_container(widget.bot) + .$result_service_provider_name() + .handle_selection(SearchSelectWidget.get_value(widget.message)) + ), + ), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> SearchSelectWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.search_select( + label=widget_service.build_label(), + options=widget_service.build_options(), + ) +""" +) + + +WIDGET_FLOW_MULTI_SELECT_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import MultiSelectWidget, RunnerHookResult, on_data_key, widget_command +from pybotx.widgets.multi_select import MULTI_SELECT_VALUE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +def _resolve_multi_select_result(widget: MultiSelectWidget) -> RunnerHookResult | None: + container = get_container(widget.bot) + widget_service = container.$widget_service_provider_name() + selected_values = widget.selected_values + if len(selected_values) < widget_service.max_selected(): + return None + + return RunnerHookResult( + result=container.$result_service_provider_name().handle_selection(selected_values), + should_display=False, + ) + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key(MULTI_SELECT_VALUE_KEY, _resolve_multi_select_result), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> MultiSelectWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.multi_select( + label=widget_service.build_label(), + options=widget_service.build_options(), + max_selected=widget_service.max_selected(), + ) +""" +) + + +WIDGET_FLOW_DATE_RANGE_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage +from pybotx.widgets import DateRangeWidget, RunnerHookResult, on_data_key, widget_command +from pybotx.widgets.date_range import DATE_RANGE_SELECTED_DATE_KEY + +from $widget_support_module import ( + build_widget_factory, + build_widget_runner_config, + get_container, +) + + +collector = HandlerCollector() +COMMAND_NAME = $command_literal + + +def _resolve_date_range_result(widget: DateRangeWidget) -> RunnerHookResult | None: + if widget.start is None or widget.end is None: + return None + + return RunnerHookResult( + result=( + get_container(widget.bot) + .$result_service_provider_name() + .handle_selection(widget.start, widget.end) + ), + should_display=False, + ) + + +@collector.command(COMMAND_NAME, description=$description_literal) +@widget_command( + config=build_widget_runner_config( + before=on_data_key(DATE_RANGE_SELECTED_DATE_KEY, _resolve_date_range_result), + ), +) +async def $handler_name(message: IncomingMessage, bot: Bot) -> DateRangeWidget: + widget_service = get_container(bot).$widget_service_provider_name() + widgets = build_widget_factory(message, bot, command=COMMAND_NAME) + return widgets.date_range(label=widget_service.build_label()) +""" +) + + +WIDGET_FLOW_CONFIRM_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.confirm import CONFIRM_ACTION, CONFIRM_ACTION_KEY + + +async def test_${module_name}_widget_flow_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_flow_handles_action( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {CONFIRM_ACTION_KEY: CONFIRM_ACTION} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_FLOW_APPROVAL_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.approval import APPROVAL_ACTION_KEY, APPROVE_ACTION + + +async def test_${module_name}_widget_flow_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_flow_handles_action( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {APPROVAL_ACTION_KEY: APPROVE_ACTION} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $primary_result_literal # type: ignore[attr-defined] +""" +) + + +WIDGET_FLOW_SELECT_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.select import SELECT_VALUE_KEY + + +async def test_${module_name}_widget_flow_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_flow_handles_selection( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.data = {SELECT_VALUE_KEY: "prod"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == "Selected value: prod" # type: ignore[attr-defined] +""" +) + + +WIDGET_FLOW_MULTI_SELECT_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.multi_select import MULTI_SELECTED_VALUES_KEY, MULTI_SELECT_VALUE_KEY + + +async def test_${module_name}_widget_flow_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $label_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_flow_handles_completed_selection( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.metadata = { + MULTI_SELECTED_VALUES_KEY: ["dev"], + } + message.data = {MULTI_SELECT_VALUE_KEY: "prod"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == "Selected values: dev, prod" # type: ignore[attr-defined] +""" +) + + +WIDGET_FLOW_DATE_RANGE_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage +from pybotx.widgets.date_range import ( + DATE_RANGE_CURSOR_KEY, + DATE_RANGE_SELECTED_DATE_KEY, + DATE_RANGE_START_KEY, +) + + +async def test_${module_name}_widget_flow_displays( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == $display_body_literal # type: ignore[attr-defined] + + +async def test_${module_name}_widget_flow_handles_completed_range( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body=$command_literal) + message.metadata = { + DATE_RANGE_START_KEY: "2026-03-01", + DATE_RANGE_CURSOR_KEY: "end", + } + message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-03-08"} + + await bot.async_execute_bot_command(message) + + bot.send.assert_awaited_once() # type: ignore[attr-defined] + assert bot.send.await_args.kwargs["message"].body == "Selected period: 2026-03-01 - 2026-03-08" # type: ignore[attr-defined] +""" +) + + +FSM_FLOW_COMMAND_TEMPLATE = Template( + """from pybotx import Bot, HandlerCollector, IncomingMessage + +from $fsm_module_prefix.$module_name import $start_function_name + + +collector = HandlerCollector() + + +@collector.command($command_literal, description=$description_literal) +async def $handler_name(message: IncomingMessage, bot: Bot) -> None: + await $start_function_name(message, bot) +""" +) + + +FSM_FLOW_MODULE_TEMPLATE = Template( + """from enum import Enum, auto + +from pybotx import Bot, IncomingMessage +from pybotx_fsm import FSMCollector + + +class $states_enum_name(Enum): + ENTER_VALUE = auto() + CONFIRM_VALUE = auto() + + +fsm = FSMCollector($states_enum_name) + + +async def $start_function_name(message: IncomingMessage, bot: Bot) -> None: + await message.state.fsm.change_state($states_enum_name.ENTER_VALUE) + await bot.answer_message($initial_prompt_literal) + + +@fsm.on($states_enum_name.ENTER_VALUE) +async def enter_value(message: IncomingMessage, bot: Bot) -> None: + value = message.body.strip() + if not value: + await bot.answer_message("Value can't be empty. Try again.") + return + + await message.state.fsm.change_state($states_enum_name.CONFIRM_VALUE, value=value) + await bot.answer_message( + f"Confirm $display_name_lower value: {value}. Reply with yes or no.", + ) + + +@fsm.on($states_enum_name.CONFIRM_VALUE) +async def confirm_value(message: IncomingMessage, bot: Bot) -> None: + decision = message.body.strip().lower() + value = message.state.fsm_storage.value + + if decision in {"yes", "y"}: + await message.state.fsm.drop_state() + $completion_statement + return + + if decision in {"no", "n"}: + await message.state.fsm.change_state($states_enum_name.ENTER_VALUE) + await bot.answer_message($initial_prompt_literal) + return + + await bot.answer_message("Reply with yes or no.") +""" +) + + +FSM_FLOW_TEST_TEMPLATE = Template( + """from collections.abc import Callable + +from pybotx import Bot, IncomingMessage + + +async def test_${module_name}_fsm_flow( + bot: Bot, + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + await bot.async_execute_bot_command(incoming_message_factory(body=$command_literal)) + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + $initial_prompt_literal, + ) + + bot.answer_message.reset_mock() # type: ignore[attr-defined] + await bot.async_execute_bot_command( + incoming_message_factory(body="sample-value"), + ) + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + "Confirm $display_name_lower value: sample-value. Reply with yes or no.", + ) + + bot.answer_message.reset_mock() # type: ignore[attr-defined] + await bot.async_execute_bot_command(incoming_message_factory(body="yes")) + bot.answer_message.assert_awaited_once_with( # type: ignore[attr-defined] + $completion_assert_literal, + ) +""" +) + + +DOCKERFILE_TEMPLATE = Template( + """FROM python:3.12-slim + +RUN pip install --no-cache-dir uv + +WORKDIR /app +COPY . . + +RUN uv sync --no-dev + +CMD ["uv", "run", "uvicorn", "$package_name.main:app", "--host", "0.0.0.0", "--port", "8000"] +""" +) + + +DOCKER_COMPOSE_TEMPLATE = Template( + """services: + bot: + build: . + env_file: + - .env + ports: + - "8000:8000" +""" +) diff --git a/pybotx/scaffold/widget.py b/pybotx/scaffold/widget.py new file mode 100644 index 00000000..39aec4e9 --- /dev/null +++ b/pybotx/scaffold/widget.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from string import Template +from typing import Literal + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + WIDGET_APPROVAL_COMMAND_TEMPLATE, + WIDGET_APPROVAL_SERVICE_TEMPLATE, + WIDGET_APPROVAL_TEST_TEMPLATE, + WIDGET_CONFIRM_COMMAND_TEMPLATE, + WIDGET_CONFIRM_SERVICE_TEMPLATE, + WIDGET_CONFIRM_TEST_TEMPLATE, + WIDGET_DATE_RANGE_COMMAND_TEMPLATE, + WIDGET_DATE_RANGE_SERVICE_TEMPLATE, + WIDGET_DATE_RANGE_TEST_TEMPLATE, + WIDGET_MULTI_SELECT_COMMAND_TEMPLATE, + WIDGET_MULTI_SELECT_SERVICE_TEMPLATE, + WIDGET_MULTI_SELECT_TEST_TEMPLATE, + WIDGET_SEARCH_SELECT_COMMAND_TEMPLATE, + WIDGET_SEARCH_SELECT_TEST_TEMPLATE, + WIDGET_SELECT_COMMAND_TEMPLATE, + WIDGET_SELECT_SERVICE_TEMPLATE, + WIDGET_SELECT_TEST_TEMPLATE, + WIDGET_SUPPORT_TEMPLATE, +) + + +WidgetKind = Literal[ + "confirm", + "approval", + "select", + "search-select", + "multi-select", + "date-range", +] + +_COMMAND_IMPORTS_START = "# pybotx-scaffold command imports start" +_COMMAND_IMPORTS_END = "# pybotx-scaffold command imports end" +_COMMAND_COLLECTORS_START = " # pybotx-scaffold command collectors start" +_COMMAND_COLLECTORS_END = " # pybotx-scaffold command collectors end" +_SERVICE_IMPORTS_START = "# pybotx-scaffold service imports start" +_SERVICE_IMPORTS_END = "# pybotx-scaffold service imports end" +_SERVICE_PROVIDERS_START = " # pybotx-scaffold service providers start" +_SERVICE_PROVIDERS_END = " # pybotx-scaffold service providers end" +_ANSWER_MESSAGE_MOCK = ( + " built_bot.answer_message = AsyncMock(return_value=uuid4()) # type: ignore[method-assign]" +) +_SEND_MESSAGE_MOCK = ( + " built_bot.send = AsyncMock(return_value=uuid4()) # type: ignore[method-assign]" +) +_EDIT_MESSAGE_MOCK = " built_bot.edit_message = AsyncMock() # type: ignore[method-assign]" + + +@dataclass(frozen=True, slots=True) +class CreateWidgetOptions: + widget_name: str + project_dir: Path | str = "." + kind: WidgetKind = "confirm" + command_name: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedWidgetArtifacts: + project_root: Path + package_name: str + widget_name: str + kind: WidgetKind + command_path: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _WidgetSpec: + kind: WidgetKind + module_name: str + widget_name: str + command_path: str + description: str + service_class_name: str + service_provider_name: str + collector_alias: str + handler_name: str + label: str + primary_result: str + secondary_result: str + tertiary_result: str | None = None + + +def create_widget_in_project(options: CreateWidgetOptions) -> CreatedWidgetArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + widget_spec = _build_widget_spec( + widget_name=options.widget_name, + kind=options.kind, + command_name=options.command_name, + description=options.description, + ) + + command_module_path = project_root / manifest.commands_dir / ( + f"{widget_spec.module_name}.py" + ) + service_module_path = project_root / manifest.services_dir / ( + f"{widget_spec.module_name}.py" + ) + test_module_path = project_root / manifest.tests_dir / ( + f"test_{widget_spec.module_name}_widget.py" + ) + widget_support_path = project_root / manifest.widget_support_path + + for path in (command_module_path, service_module_path, test_module_path): + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + created_files: list[Path] = [] + if not widget_support_path.exists(): + write_template( + widget_support_path, + WIDGET_SUPPORT_TEMPLATE, + {"container_module": manifest.container_module}, + ) + created_files.append(widget_support_path) + + _ensure_widget_test_mocks(project_root / manifest.tests_conftest_path) + + command_template, service_template, test_template = _select_widget_templates( + widget_spec.kind, + ) + context = { + "command_literal": repr(widget_spec.command_path), + "description_literal": repr(widget_spec.description), + "display_body_literal": repr( + f"{widget_spec.label}\nНачало: —\nКонец: —", + ), + "handler_name": widget_spec.handler_name, + "label_literal": repr(widget_spec.label), + "module_name": widget_spec.module_name, + "package_name": manifest.package_name, + "primary_result_literal": repr(widget_spec.primary_result), + "secondary_result_literal": repr(widget_spec.secondary_result), + "service_class_name": widget_spec.service_class_name, + "service_provider_name": widget_spec.service_provider_name, + "tertiary_result_literal": repr(widget_spec.tertiary_result or ""), + "widget_support_module": manifest.widget_support_module, + } + + write_template(command_module_path, command_template, context) + write_template(service_module_path, service_template, context) + write_template(test_module_path, test_template, context) + created_files.extend( + [ + command_module_path, + service_module_path, + test_module_path, + ], + ) + + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_IMPORTS_START, + end_marker=_COMMAND_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.commands_module_prefix}.{widget_spec.module_name} " + f"import collector as {widget_spec.collector_alias}" + ), + ], + ) + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_COLLECTORS_START, + end_marker=_COMMAND_COLLECTORS_END, + new_lines=[f" {widget_spec.collector_alias},"], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_IMPORTS_START, + end_marker=_SERVICE_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.services_module_prefix}.{widget_spec.module_name} " + f"import {widget_spec.service_class_name}" + ), + ], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_PROVIDERS_START, + end_marker=_SERVICE_PROVIDERS_END, + new_lines=[ + ( + f" {widget_spec.service_provider_name} = providers.Factory(" + f"{widget_spec.service_class_name})" + ), + ], + ) + + return CreatedWidgetArtifacts( + project_root=project_root, + package_name=manifest.package_name, + widget_name=widget_spec.widget_name, + kind=widget_spec.kind, + command_path=widget_spec.command_path, + created_files=tuple(created_files), + ) + + +def _build_widget_spec( + *, + widget_name: str, + kind: WidgetKind, + command_name: str | None, + description: str | None, +) -> _WidgetSpec: + normalized_widget_name = normalize_scaffold_name(widget_name, kind="Widget name") + normalized_command_name = normalize_scaffold_name( + command_name or widget_name, + kind="Command name", + ) + module_name = slug_to_module_name(normalized_widget_name) + display_name = normalized_widget_name.replace("-", " ") + pascal_name = module_name_to_pascal_case(module_name) + + if kind == "confirm": + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} confirmation widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Confirm {display_name}?", + primary_result=f"{display_name.title()} confirmed.", + secondary_result=f"{display_name.title()} cancelled.", + ) + + if kind == "approval": + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} approval widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Review {display_name}", + primary_result=f"{display_name.title()} approved.", + secondary_result=f"{display_name.title()} rejected.", + tertiary_result=f"Left a comment for {display_name}.", + ) + + if kind == "search-select": + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} search select widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected value: prod", + secondary_result="", + ) + + if kind == "multi-select": + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} multi select widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected values: dev, prod", + secondary_result="", + ) + + if kind == "date-range": + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} date range widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name} period", + primary_result="Selected period: 2026-03-01 - 2026-03-08", + secondary_result="", + ) + + return _WidgetSpec( + kind=kind, + module_name=module_name, + widget_name=normalized_widget_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} select widget", + service_class_name=f"{pascal_name}WidgetService", + service_provider_name=f"{module_name}_widget_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected value: prod", + secondary_result="", + ) + + +def _select_widget_templates( + kind: WidgetKind, +) -> tuple[Template, Template, Template]: + if kind == "confirm": + return ( + WIDGET_CONFIRM_COMMAND_TEMPLATE, + WIDGET_CONFIRM_SERVICE_TEMPLATE, + WIDGET_CONFIRM_TEST_TEMPLATE, + ) + if kind == "approval": + return ( + WIDGET_APPROVAL_COMMAND_TEMPLATE, + WIDGET_APPROVAL_SERVICE_TEMPLATE, + WIDGET_APPROVAL_TEST_TEMPLATE, + ) + if kind == "search-select": + return ( + WIDGET_SEARCH_SELECT_COMMAND_TEMPLATE, + WIDGET_SELECT_SERVICE_TEMPLATE, + WIDGET_SEARCH_SELECT_TEST_TEMPLATE, + ) + if kind == "multi-select": + return ( + WIDGET_MULTI_SELECT_COMMAND_TEMPLATE, + WIDGET_MULTI_SELECT_SERVICE_TEMPLATE, + WIDGET_MULTI_SELECT_TEST_TEMPLATE, + ) + if kind == "date-range": + return ( + WIDGET_DATE_RANGE_COMMAND_TEMPLATE, + WIDGET_DATE_RANGE_SERVICE_TEMPLATE, + WIDGET_DATE_RANGE_TEST_TEMPLATE, + ) + return ( + WIDGET_SELECT_COMMAND_TEMPLATE, + WIDGET_SELECT_SERVICE_TEMPLATE, + WIDGET_SELECT_TEST_TEMPLATE, + ) + + +def _ensure_widget_test_mocks(path: Path) -> None: + content = path.read_text(encoding="utf-8") + if _SEND_MESSAGE_MOCK in content and _EDIT_MESSAGE_MOCK in content: + return + + if _ANSWER_MESSAGE_MOCK not in content: + raise RuntimeError( + "Couldn't enable widget test support automatically for " + f"`{path}`. Regenerate the project or add send/edit mocks manually.", + ) + + replacement_lines = [ + _ANSWER_MESSAGE_MOCK, + _SEND_MESSAGE_MOCK, + _EDIT_MESSAGE_MOCK, + ] + updated_content = content.replace( + _ANSWER_MESSAGE_MOCK, + "\n".join(replacement_lines), + 1, + ) + path.write_text(updated_content, encoding="utf-8") diff --git a/pybotx/scaffold/widget_flow.py b/pybotx/scaffold/widget_flow.py new file mode 100644 index 00000000..5c27ff3c --- /dev/null +++ b/pybotx/scaffold/widget_flow.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from string import Template + +from pybotx.scaffold._shared import ( + append_marked_lines, + module_name_to_pascal_case, + normalize_scaffold_name, + slug_to_module_name, + write_template, +) +from pybotx.scaffold.manifest import ( + ScaffoldProjectManifest, + find_scaffold_project_root, +) +from pybotx.scaffold.templates import ( + WIDGET_FLOW_APPROVAL_COMMAND_TEMPLATE, + WIDGET_FLOW_APPROVAL_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_APPROVAL_TEST_TEMPLATE, + WIDGET_FLOW_CONFIRM_COMMAND_TEMPLATE, + WIDGET_FLOW_CONFIRM_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_CONFIRM_TEST_TEMPLATE, + WIDGET_FLOW_DATE_RANGE_COMMAND_TEMPLATE, + WIDGET_FLOW_DATE_RANGE_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_DATE_RANGE_TEST_TEMPLATE, + WIDGET_FLOW_LABEL_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_MULTI_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_MULTI_SELECTION_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_MULTI_SELECT_TEST_TEMPLATE, + WIDGET_FLOW_MULTI_SELECT_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_OPTIONS_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_SEARCH_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_SELECT_TEST_TEMPLATE, + WIDGET_FLOW_SELECTION_RESULT_SERVICE_TEMPLATE, + WIDGET_SUPPORT_TEMPLATE, +) +from pybotx.scaffold.widget import WidgetKind + + +_COMMAND_IMPORTS_START = "# pybotx-scaffold command imports start" +_COMMAND_IMPORTS_END = "# pybotx-scaffold command imports end" +_COMMAND_COLLECTORS_START = " # pybotx-scaffold command collectors start" +_COMMAND_COLLECTORS_END = " # pybotx-scaffold command collectors end" +_SERVICE_IMPORTS_START = "# pybotx-scaffold service imports start" +_SERVICE_IMPORTS_END = "# pybotx-scaffold service imports end" +_SERVICE_PROVIDERS_START = " # pybotx-scaffold service providers start" +_SERVICE_PROVIDERS_END = " # pybotx-scaffold service providers end" +_ANSWER_MESSAGE_MOCK = ( + " built_bot.answer_message = AsyncMock(return_value=uuid4()) # type: ignore[method-assign]" +) +_SEND_MESSAGE_MOCK = ( + " built_bot.send = AsyncMock(return_value=uuid4()) # type: ignore[method-assign]" +) +_EDIT_MESSAGE_MOCK = " built_bot.edit_message = AsyncMock() # type: ignore[method-assign]" + + +@dataclass(frozen=True, slots=True) +class CreateWidgetFlowOptions: + flow_name: str + project_dir: Path | str = "." + kind: WidgetKind = "confirm" + command_name: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreatedWidgetFlowArtifacts: + project_root: Path + package_name: str + flow_name: str + kind: WidgetKind + command_path: str + created_files: tuple[Path, ...] + + +@dataclass(frozen=True, slots=True) +class _WidgetFlowSpec: + kind: WidgetKind + module_name: str + flow_name: str + command_path: str + description: str + widget_service_class_name: str + widget_service_provider_name: str + result_service_class_name: str + result_service_provider_name: str + collector_alias: str + handler_name: str + label: str + primary_result: str + secondary_result: str + tertiary_result: str | None = None + + +def create_widget_flow_in_project( + options: CreateWidgetFlowOptions, +) -> CreatedWidgetFlowArtifacts: + project_root = find_scaffold_project_root(options.project_dir) + manifest = ScaffoldProjectManifest.load(project_root) + flow_spec = _build_widget_flow_spec( + flow_name=options.flow_name, + kind=options.kind, + command_name=options.command_name, + description=options.description, + ) + + command_module_path = project_root / manifest.commands_dir / f"{flow_spec.module_name}.py" + widget_service_module_path = ( + project_root / manifest.services_dir / f"{flow_spec.module_name}_widget.py" + ) + result_service_module_path = ( + project_root / manifest.services_dir / f"{flow_spec.module_name}_result.py" + ) + test_module_path = ( + project_root / manifest.tests_dir / f"test_{flow_spec.module_name}_widget_flow.py" + ) + widget_support_path = project_root / manifest.widget_support_path + + for path in ( + command_module_path, + widget_service_module_path, + result_service_module_path, + test_module_path, + ): + if path.exists(): + raise FileExistsError(f"`{path}` already exists") + + created_files: list[Path] = [] + if not widget_support_path.exists(): + write_template( + widget_support_path, + WIDGET_SUPPORT_TEMPLATE, + {"container_module": manifest.container_module}, + ) + created_files.append(widget_support_path) + + _ensure_widget_test_mocks(project_root / manifest.tests_conftest_path) + + ( + command_template, + widget_service_template, + result_service_template, + test_template, + ) = _select_widget_flow_templates(flow_spec.kind) + + context = { + "command_literal": repr(flow_spec.command_path), + "description_literal": repr(flow_spec.description), + "display_body_literal": repr(f"{flow_spec.label}\nНачало: —\nКонец: —"), + "handler_name": flow_spec.handler_name, + "label_literal": repr(flow_spec.label), + "module_name": flow_spec.module_name, + "package_name": manifest.package_name, + "primary_result_literal": repr(flow_spec.primary_result), + "result_service_class_name": flow_spec.result_service_class_name, + "result_service_provider_name": flow_spec.result_service_provider_name, + "secondary_result_literal": repr(flow_spec.secondary_result), + "tertiary_result_literal": repr(flow_spec.tertiary_result or ""), + "widget_service_class_name": flow_spec.widget_service_class_name, + "widget_service_provider_name": flow_spec.widget_service_provider_name, + "widget_support_module": manifest.widget_support_module, + } + + write_template(command_module_path, command_template, context) + write_template(widget_service_module_path, widget_service_template, context) + write_template(result_service_module_path, result_service_template, context) + write_template(test_module_path, test_template, context) + created_files.extend( + [ + command_module_path, + widget_service_module_path, + result_service_module_path, + test_module_path, + ], + ) + + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_IMPORTS_START, + end_marker=_COMMAND_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.commands_module_prefix}.{flow_spec.module_name} " + f"import collector as {flow_spec.collector_alias}" + ), + ], + ) + append_marked_lines( + project_root / manifest.commands_init_path, + start_marker=_COMMAND_COLLECTORS_START, + end_marker=_COMMAND_COLLECTORS_END, + new_lines=[f" {flow_spec.collector_alias},"], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_IMPORTS_START, + end_marker=_SERVICE_IMPORTS_END, + new_lines=[ + ( + f"from {manifest.services_module_prefix}.{flow_spec.module_name}_widget " + f"import {flow_spec.widget_service_class_name}" + ), + ( + f"from {manifest.services_module_prefix}.{flow_spec.module_name}_result " + f"import {flow_spec.result_service_class_name}" + ), + ], + ) + append_marked_lines( + project_root / manifest.container_path, + start_marker=_SERVICE_PROVIDERS_START, + end_marker=_SERVICE_PROVIDERS_END, + new_lines=[ + ( + f" {flow_spec.widget_service_provider_name} = providers.Factory(" + f"{flow_spec.widget_service_class_name})" + ), + ( + f" {flow_spec.result_service_provider_name} = providers.Factory(" + f"{flow_spec.result_service_class_name})" + ), + ], + ) + + return CreatedWidgetFlowArtifacts( + project_root=project_root, + package_name=manifest.package_name, + flow_name=flow_spec.flow_name, + kind=flow_spec.kind, + command_path=flow_spec.command_path, + created_files=tuple(created_files), + ) + + +def _build_widget_flow_spec( + *, + flow_name: str, + kind: WidgetKind, + command_name: str | None, + description: str | None, +) -> _WidgetFlowSpec: + normalized_flow_name = normalize_scaffold_name(flow_name, kind="Widget flow name") + normalized_command_name = normalize_scaffold_name( + command_name or flow_name, + kind="Command name", + ) + module_name = slug_to_module_name(normalized_flow_name) + display_name = normalized_flow_name.replace("-", " ") + pascal_name = module_name_to_pascal_case(module_name) + + widget_service_class_name = f"{pascal_name}WidgetService" + result_service_class_name = f"{pascal_name}ResultService" + + if kind == "confirm": + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} confirmation widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Confirm {display_name}?", + primary_result=f"{display_name.title()} confirmed.", + secondary_result=f"{display_name.title()} cancelled.", + ) + + if kind == "approval": + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} approval widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Review {display_name}", + primary_result=f"{display_name.title()} approved.", + secondary_result=f"{display_name.title()} rejected.", + tertiary_result=f"Left a comment for {display_name}.", + ) + + if kind == "search-select": + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} search select widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected value: prod", + secondary_result="", + ) + + if kind == "multi-select": + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} multi select widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected values: dev, prod", + secondary_result="", + ) + + if kind == "date-range": + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} date range widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name} period", + primary_result="Selected period: 2026-03-01 - 2026-03-08", + secondary_result="", + ) + + return _WidgetFlowSpec( + kind=kind, + module_name=module_name, + flow_name=normalized_flow_name, + command_path=f"/{normalized_command_name}", + description=description or f"Show {display_name} select widget flow", + widget_service_class_name=widget_service_class_name, + widget_service_provider_name=f"{module_name}_widget_service", + result_service_class_name=result_service_class_name, + result_service_provider_name=f"{module_name}_result_service", + collector_alias=f"{module_name}_collector", + handler_name=f"{module_name}_handler", + label=f"Choose {display_name}", + primary_result="Selected value: prod", + secondary_result="", + ) + + +def _select_widget_flow_templates( + kind: WidgetKind, +) -> tuple[Template, Template, Template, Template]: + if kind == "confirm": + return ( + WIDGET_FLOW_CONFIRM_COMMAND_TEMPLATE, + WIDGET_FLOW_LABEL_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_CONFIRM_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_CONFIRM_TEST_TEMPLATE, + ) + if kind == "approval": + return ( + WIDGET_FLOW_APPROVAL_COMMAND_TEMPLATE, + WIDGET_FLOW_LABEL_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_APPROVAL_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_APPROVAL_TEST_TEMPLATE, + ) + if kind == "search-select": + return ( + WIDGET_FLOW_SEARCH_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_OPTIONS_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_SELECTION_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_SELECT_TEST_TEMPLATE, + ) + if kind == "multi-select": + return ( + WIDGET_FLOW_MULTI_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_MULTI_SELECT_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_MULTI_SELECTION_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_MULTI_SELECT_TEST_TEMPLATE, + ) + if kind == "date-range": + return ( + WIDGET_FLOW_DATE_RANGE_COMMAND_TEMPLATE, + WIDGET_FLOW_LABEL_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_DATE_RANGE_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_DATE_RANGE_TEST_TEMPLATE, + ) + return ( + WIDGET_FLOW_SELECT_COMMAND_TEMPLATE, + WIDGET_FLOW_OPTIONS_WIDGET_SERVICE_TEMPLATE, + WIDGET_FLOW_SELECTION_RESULT_SERVICE_TEMPLATE, + WIDGET_FLOW_SELECT_TEST_TEMPLATE, + ) + + +def _ensure_widget_test_mocks(path: Path) -> None: + content = path.read_text(encoding="utf-8") + if _SEND_MESSAGE_MOCK in content and _EDIT_MESSAGE_MOCK in content: + return + + if _ANSWER_MESSAGE_MOCK not in content: + raise RuntimeError( + "Couldn't enable widget test support automatically for " + f"`{path}`. Regenerate the project or add send/edit mocks manually.", + ) + + replacement_lines = [ + _ANSWER_MESSAGE_MOCK, + _SEND_MESSAGE_MOCK, + _EDIT_MESSAGE_MOCK, + ] + updated_content = content.replace( + _ANSWER_MESSAGE_MOCK, + "\n".join(replacement_lines), + 1, + ) + path.write_text(updated_content, encoding="utf-8") diff --git a/pybotx/widgets/__init__.py b/pybotx/widgets/__init__.py new file mode 100644 index 00000000..8b6b0b10 --- /dev/null +++ b/pybotx/widgets/__init__.py @@ -0,0 +1,94 @@ +from pybotx.widgets.approval import ApprovalWidget +from pybotx.widgets.async_job import AsyncJobWidget +from pybotx.widgets.calendar import CalendarWidget +from pybotx.widgets.carousel import CarouselWidget +from pybotx.widgets.checklist import CheckListWidget +from pybotx.widgets.checktable import CheckboxContent, ChecktableWidget +from pybotx.widgets.confirm import ConfirmWidget +from pybotx.widgets.date_range import DateRangeWidget +from pybotx.widgets.factory import WidgetContext, WidgetDefaults, WidgetFactory +from pybotx.widgets.file_batch import BatchFileItem, FileBatchWidget +from pybotx.widgets.form_wizard import FormWizardStep, FormWizardWidget +from pybotx.widgets.markup import MessageMarkup +from pybotx.widgets.messages_pager import MessagesPagerWidget +from pybotx.widgets.multi_select import MultiSelectWidget +from pybotx.widgets.observability import ( + InMemoryWidgetRunnerMetricsCollector, + NoopWidgetRunnerMetricsCollector, + PrometheusWidgetRunnerMetricsCollector, + WidgetRunnerMetricsSnapshot, +) +from pybotx.widgets.pagination import PaginationWidget +from pybotx.widgets.presets import ( + WidgetRunnerObservabilityPreset, + WidgetRunnerPreset, + build_production_widget_runner_preset, + build_widget_runner_observability_preset, +) +from pybotx.widgets.range import RangeWidget +from pybotx.widgets.runner import ( + LoggingWidgetRunnerObserver, + RunnerHookResult, + WidgetRunner, + WidgetRunnerConfig, + WidgetRunnerFinishedEvent, + WidgetRunnerObserver, + WidgetRunnerStartedEvent, + on_action, + on_completed, + on_data_key, + widget_command, +) +from pybotx.widgets.search_select import SearchSelectWidget +from pybotx.widgets.select import SelectOption, SelectWidget +from pybotx.widgets.table import TableWidget +from pybotx.widgets.undefined import Undefined, undefined + +__all__ = [ + "ApprovalWidget", + "AsyncJobWidget", + "BatchFileItem", + "CalendarWidget", + "CarouselWidget", + "CheckListWidget", + "ConfirmWidget", + "ChecktableWidget", + "CheckboxContent", + "DateRangeWidget", + "FileBatchWidget", + "FormWizardStep", + "FormWizardWidget", + "InMemoryWidgetRunnerMetricsCollector", + "MessageMarkup", + "MessagesPagerWidget", + "MultiSelectWidget", + "NoopWidgetRunnerMetricsCollector", + "PaginationWidget", + "PrometheusWidgetRunnerMetricsCollector", + "RangeWidget", + "RunnerHookResult", + "SearchSelectWidget", + "SelectOption", + "SelectWidget", + "TableWidget", + "LoggingWidgetRunnerObserver", + "WidgetContext", + "WidgetDefaults", + "WidgetFactory", + "WidgetRunner", + "WidgetRunnerConfig", + "WidgetRunnerFinishedEvent", + "WidgetRunnerObserver", + "WidgetRunnerObservabilityPreset", + "WidgetRunnerPreset", + "WidgetRunnerMetricsSnapshot", + "WidgetRunnerStartedEvent", + "build_production_widget_runner_preset", + "build_widget_runner_observability_preset", + "on_action", + "on_completed", + "on_data_key", + "widget_command", + "Undefined", + "undefined", +] diff --git a/pybotx/widgets/approval.py b/pybotx/widgets/approval.py new file mode 100644 index 00000000..ce50fa85 --- /dev/null +++ b/pybotx/widgets/approval.py @@ -0,0 +1,58 @@ +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +APPROVAL_ACTION_KEY = "approval_action" +APPROVE_ACTION = "approve" +REJECT_ACTION = "reject" +COMMENT_ACTION = "comment" + + +class ApprovalWidget(Widget): + def __init__( + self, + label: str, + approve_label: str = "Согласовать", + reject_label: str = "Отклонить", + comment_label: str = "Комментарий", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.widget_message.body = label + self.approve_label = approve_label + self.reject_label = reject_label + self.comment_label = comment_label + + def add_markup(self) -> None: + self.widget_bubbles.add_button( + command=self.command, + label=self.approve_label, + data={APPROVAL_ACTION_KEY: APPROVE_ACTION}, + ) + self.widget_bubbles.add_button( + command=self.command, + label=self.reject_label, + data={APPROVAL_ACTION_KEY: REJECT_ACTION}, + new_row=False, + ) + self.widget_bubbles.add_button( + command=self.command, + label=self.comment_label, + data={APPROVAL_ACTION_KEY: COMMENT_ACTION}, + ) + self.add_additional_markup() + + @classmethod + def get_action(cls, message: IncomingMessage) -> str: + action = message.data.get(APPROVAL_ACTION_KEY) + if action not in {APPROVE_ACTION, REJECT_ACTION, COMMENT_ACTION}: + raise RuntimeError("Approval action is not found.") + + return str(action) + + @classmethod + def is_approved(cls, message: IncomingMessage) -> bool: + return cls.get_action(message) == APPROVE_ACTION diff --git a/pybotx/widgets/async_job.py b/pybotx/widgets/async_job.py new file mode 100644 index 00000000..a31e2d43 --- /dev/null +++ b/pybotx/widgets/async_job.py @@ -0,0 +1,93 @@ +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +ASYNC_JOB_ID_KEY = "async_job_id" +ASYNC_JOB_STATUS_KEY = "async_job_status" +ASYNC_JOB_ACTION_KEY = "async_job_action" + +ASYNC_JOB_QUEUED = "queued" +ASYNC_JOB_RUNNING = "running" +ASYNC_JOB_DONE = "done" +ASYNC_JOB_FAILED = "failed" +ASYNC_JOB_CANCELLED = "cancelled" + +ASYNC_JOB_ACTION_REFRESH = "refresh" +ASYNC_JOB_ACTION_RETRY = "retry" +ASYNC_JOB_ACTION_CANCEL = "cancel" + + +class AsyncJobWidget(Widget): + def __init__( + self, + job_id: str, + status: str, + details: str = "", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.job_id = job_id + self.status = status + self.details = details + self.widget_message.body = self._build_body() + self.widget_metadata[ASYNC_JOB_ID_KEY] = self.job_id + self.widget_metadata[ASYNC_JOB_STATUS_KEY] = self.status + + def add_markup(self) -> None: + if self.status in {ASYNC_JOB_QUEUED, ASYNC_JOB_RUNNING}: + self.widget_bubbles.add_button( + command=self.command, + label="Обновить", + data={ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_REFRESH}, + ) + self.widget_bubbles.add_button( + command=self.command, + label="Отменить", + data={ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_CANCEL}, + new_row=False, + ) + self.add_additional_markup() + return + + if self.status == ASYNC_JOB_FAILED: + self.widget_bubbles.add_button( + command=self.command, + label="Повторить", + data={ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_RETRY}, + ) + + self.widget_bubbles.add_button( + command=self.command, + label="Обновить", + data={ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_REFRESH}, + new_row=self.status != ASYNC_JOB_FAILED, + ) + self.add_additional_markup() + + def _build_body(self) -> str: + body = f"Задача {self.job_id}\nСтатус: {self.status}" + if self.details: + body = f"{body}\n{self.details}" + return body + + @classmethod + def get_action(cls, message: IncomingMessage) -> str: + action = message.data.get(ASYNC_JOB_ACTION_KEY) + if action not in { + ASYNC_JOB_ACTION_REFRESH, + ASYNC_JOB_ACTION_RETRY, + ASYNC_JOB_ACTION_CANCEL, + }: + raise RuntimeError("Async job action is not found.") + + return str(action) + + @classmethod + def get_status(cls, message: IncomingMessage) -> str: + status = message.metadata.get(ASYNC_JOB_STATUS_KEY) + if status is None: + raise RuntimeError("Async job status is not found.") + return str(status) diff --git a/pybotx/widgets/base.py b/pybotx/widgets/base.py new file mode 100644 index 00000000..d6722447 --- /dev/null +++ b/pybotx/widgets/base.py @@ -0,0 +1,241 @@ +from abc import ABC, abstractmethod +from typing import Any, Literal, cast + +from pybotx.bot.bot import Bot +from pybotx.missing import Missing, Undefined +from pybotx.models.attachments import IncomingFileAttachment, OutgoingAttachment +from pybotx.models.message.incoming_message import IncomingMessage +from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup +from pybotx.models.message.outgoing_message import OutgoingMessage + +from .markup import MessageMarkup, merge_message_markup + +PYBOTX_WIDGET_FLAG = "pybotx_widget" +PYBOTX_WIDGET_RESULT_MODE_KEY = "pybotx_widget_result_mode" +WIDGET_RESULT_MODE_EDIT: Literal["edit"] = "edit" +WIDGET_RESULT_MODE_MESSAGE: Literal["message"] = "message" +WidgetResultMode = Literal["edit", "message"] + + +def _normalize_result_mode(value: object) -> WidgetResultMode | None: + if not isinstance(value, str): + return None + + normalized = value.strip().lower() + if normalized in {"edit", "inline", "update"}: + return WIDGET_RESULT_MODE_EDIT + + if normalized in {"message", "reply", "send"}: + return WIDGET_RESULT_MODE_MESSAGE + + return None + + +def _parse_result_mode_from_argument(argument: str) -> WidgetResultMode | None: + for token in argument.split(): + if parsed_mode := _normalize_result_mode(token): + return parsed_mode + + normalized_token = token.strip().lower() + for prefix in ("mode=", "result=", "--mode=", "--result="): + if not normalized_token.startswith(prefix): + continue + + raw_mode = normalized_token.split("=", 1)[1] + return _normalize_result_mode(raw_mode) + + return None + + +def build_outgoing_message_from_incoming( + *, + message: IncomingMessage, + body: str, + metadata: Missing[dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, +) -> OutgoingMessage: + return OutgoingMessage( + bot_id=message.bot.id, + chat_id=message.chat.id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + ) + + +def outgoing_markup(message: OutgoingMessage) -> MessageMarkup: + bubbles = ensure_widget_bubbles(message) + keyboard = ensure_widget_keyboard(message) + return MessageMarkup( + bubbles=bubbles, + keyboard=keyboard, + ) + + +def ensure_widget_bubbles(message: OutgoingMessage) -> BubbleMarkup: + if message.bubbles is Undefined: + message.bubbles = BubbleMarkup() + + return cast(BubbleMarkup, message.bubbles) + + +def ensure_widget_keyboard(message: OutgoingMessage) -> KeyboardMarkup: + if message.keyboard is Undefined: + message.keyboard = KeyboardMarkup() + + return cast(KeyboardMarkup, message.keyboard) + + +def ensure_widget_metadata(message: OutgoingMessage) -> dict[str, Any]: + if message.metadata is Undefined: + message.metadata = {} + + return cast(dict[str, Any], message.metadata) + + +class Widget(ABC): + widget_message: OutgoingMessage + + def __init__( + self, + *, + message: IncomingMessage, + bot: Bot, + command: str, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> None: + self.message = message + self.bot = bot + self.command = command + self.additional_markup = additional_markup + + self.widget_message = build_outgoing_message_from_incoming( + message=message, + body="", + metadata=dict(message.metadata), + bubbles=BubbleMarkup(), + keyboard=KeyboardMarkup(), + ) + self.result_mode = self._resolve_result_mode(result_mode) + self.widget_metadata[PYBOTX_WIDGET_RESULT_MODE_KEY] = self.result_mode + + @property + def widget_bubbles(self) -> BubbleMarkup: + return ensure_widget_bubbles(self.widget_message) + + @property + def widget_keyboard(self) -> KeyboardMarkup: + return ensure_widget_keyboard(self.widget_message) + + @property + def widget_metadata(self) -> dict[str, Any]: + return ensure_widget_metadata(self.widget_message) + + @abstractmethod + def add_markup(self) -> None: + raise NotImplementedError + + def add_additional_markup(self) -> None: + if self.additional_markup is None: + return + + current_markup = outgoing_markup(self.widget_message) + merged_markup = merge_message_markup(current_markup, self.additional_markup) + self.widget_message.bubbles = merged_markup.bubbles + self.widget_message.keyboard = merged_markup.keyboard + + async def send_widget_message(self) -> None: + await self.send_or_update_message(self.widget_message) + + async def send_result( + self, + body: str, + markup: MessageMarkup | None = None, + ) -> None: + markup = markup or MessageMarkup() + + if self.result_mode == WIDGET_RESULT_MODE_MESSAGE: + await self.bot.answer_message( + body, + metadata={PYBOTX_WIDGET_RESULT_MODE_KEY: self.result_mode}, + bubbles=markup.bubbles, + keyboard=markup.keyboard, + ) + return + + await self.send_or_update_message( + build_outgoing_message_from_incoming( + message=self.message, + body=body, + metadata={ + **dict(self.message.metadata), + PYBOTX_WIDGET_RESULT_MODE_KEY: self.result_mode, + }, + bubbles=markup.bubbles, + keyboard=markup.keyboard, + ), + ) + + async def display(self) -> None: + self.add_markup() + await self.send_widget_message() + + async def send_or_update_message(self, widget_message: OutgoingMessage) -> None: + widget_message.metadata = self._build_widget_metadata(widget_message.metadata) + + if self._is_widget_update(): + assert self.message.source_sync_id is not None + await self.bot.edit_message( + bot_id=self.message.bot.id, + sync_id=self.message.source_sync_id, + body=widget_message.body, + metadata=widget_message.metadata, + bubbles=widget_message.bubbles, + keyboard=widget_message.keyboard, + file=widget_message.file, + markup_auto_adjust=widget_message.markup_auto_adjust, + ) + return + + await self.bot.send(message=widget_message) + + def _build_widget_metadata( + self, + metadata: Missing[dict[str, Any]], + ) -> dict[str, Any]: + widget_metadata: dict[str, Any] + if metadata is Undefined: + widget_metadata = dict(self.message.metadata) + else: + widget_metadata = dict(cast(dict[str, Any], metadata)) + + widget_metadata[PYBOTX_WIDGET_FLAG] = 1 + return widget_metadata + + def _is_widget_update(self) -> bool: + return bool( + self.message.metadata.get(PYBOTX_WIDGET_FLAG) + and self.message.source_sync_id is not None + ) + + def _resolve_result_mode( + self, + result_mode: WidgetResultMode | None, + ) -> WidgetResultMode: + if result_mode is not None: + return result_mode + + if metadata_mode := _normalize_result_mode( + self.message.metadata.get(PYBOTX_WIDGET_RESULT_MODE_KEY), + ): + return metadata_mode + + if argument_mode := _parse_result_mode_from_argument(self.message.argument): + return argument_mode + + return WIDGET_RESULT_MODE_EDIT diff --git a/pybotx/widgets/calendar.py b/pybotx/widgets/calendar.py new file mode 100644 index 00000000..c5ba0fa2 --- /dev/null +++ b/pybotx/widgets/calendar.py @@ -0,0 +1,255 @@ +from calendar import Calendar +from datetime import date, datetime +from typing import Any + +from pybotx.bot.bot import Bot +from pybotx.models.message.incoming_message import IncomingMessage + +from . import strings +from .base import Widget +from .markup import MessageMarkup +from .service import send_or_update_message + +MONTH_TO_DISPLAY_KEY = "calendar_month_to_display" +SELECTED_DATE_KEY = "calendar_selected_date" + + +def _parse_date(value: object) -> date | None: + if isinstance(value, date): + return value + + if not isinstance(value, str): + return None + + try: + return date.fromisoformat(value) + except ValueError: + pass + + try: + return datetime.fromisoformat(value).date() + except ValueError: + return None + + +class CalendarWidget(Widget): + LEFT_ARROW = strings.LEFT_ARROW + RIGHT_ARROW = strings.RIGHT_ARROW + AFTER_SELECT_TEXT = strings.CAL_DATE_SELECTED + SELECT_DATE = strings.SELECT_DATE + WEEKDAYS = strings.WEEKDAYS + MONTHS = strings.MONTHS + + def __init__( + self, + start_date: date | None = None, + end_date: date = date.max, + include_past: bool = False, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + + self.start_date = start_date or date.today() + self.end_date = end_date + self.include_past = include_past + + if self.start_date > self.end_date: + raise ValueError("'start_date' should be less or equal than 'end_date'") + + self.month_to_display = self.message.data.get(MONTH_TO_DISPLAY_KEY) + self.widget_message.body = self.SELECT_DATE + self.current_date = self.get_current_date() + + @classmethod + async def get_value( + cls, + message: IncomingMessage, + bot: Bot, + *, + send_feedback: bool = True, + ) -> date: + selected_date = _parse_date(message.data.get(SELECTED_DATE_KEY)) + if selected_date is None: + raise RuntimeError("Date is not selected.") + + _clear_calendar_data(message) + if send_feedback: + await send_or_update_message(message, bot, cls.AFTER_SELECT_TEXT, MessageMarkup()) + return selected_date + + def get_current_date(self) -> date: + is_navigation = ( + self.LEFT_ARROW in self.message.argument + or self.RIGHT_ARROW in self.message.argument + ) + + if is_navigation and self.month_to_display: + parsed_date = _parse_date(self.month_to_display) + if parsed_date is not None: + return parsed_date.replace(day=1) + + return date.today().replace(day=1) + + def get_prev_and_next_year(self) -> tuple[date, date]: + month = 1 if self.include_past else date.today().month + prev_year = date(self.current_date.year - 1, month, 1) + next_year = date(self.current_date.year + 1, 1, 1) + return prev_year, next_year + + def get_prev_and_next_month(self) -> tuple[date, date]: + if self.current_date.month == 1: + prev_month = date(self.current_date.year - 1, 12, 1) + else: + prev_month = date(self.current_date.year, self.current_date.month - 1, 1) + + if self.current_date.month == 12: + next_month = date(self.current_date.year + 1, 1, 1) + else: + next_month = date(self.current_date.year, self.current_date.month + 1, 1) + + return prev_month, next_month + + def add_markup(self) -> None: + self.add_year_bubbles() + self.add_month_bubbles() + self.add_week_bubbles() + self.add_day_bubbles() + self.add_additional_markup() + + def add_year_bubbles(self) -> None: + prev_year, next_year = self.get_prev_and_next_year() + can_show_prev_year = ( + self.include_past or self.start_date.year < self.current_date.year + ) + can_show_next_year = self.current_date.year < self.end_date.year + + if can_show_prev_year: + self.widget_bubbles.add_button( + command=f"{self.command} {self.LEFT_ARROW}", + label=self.LEFT_ARROW, + data={MONTH_TO_DISPLAY_KEY: prev_year.isoformat()}, + ) + else: + self.widget_bubbles.add_button(command="", label=" ") + + self.widget_bubbles.add_button( + command="", + label=str(self.current_date.year), + new_row=False, + ) + + if can_show_next_year: + self.widget_bubbles.add_button( + command=f"{self.command} {self.RIGHT_ARROW}", + label=self.RIGHT_ARROW, + data={MONTH_TO_DISPLAY_KEY: next_year.isoformat()}, + new_row=False, + ) + else: + self.widget_bubbles.add_button( + command="", + label=" ", + new_row=False, + ) + + def add_month_bubbles(self) -> None: + prev_month, next_month = self.get_prev_and_next_month() + is_lower_limit = all( + ( + self.start_date.month >= self.current_date.month, + self.start_date.year >= self.current_date.year, + ) + ) + is_upper_limit = all( + ( + self.end_date.month == self.current_date.month, + self.end_date.year == self.current_date.year, + ) + ) + + if not self.include_past and is_lower_limit: + self.widget_bubbles.add_button(command="", label=" ") + else: + self.widget_bubbles.add_button( + command=f"{self.command} {self.LEFT_ARROW}", + label=self.LEFT_ARROW, + data={MONTH_TO_DISPLAY_KEY: prev_month.isoformat()}, + ) + + self.widget_bubbles.add_button( + command="", + label=self.MONTHS[self.current_date.month], + new_row=False, + ) + + if is_upper_limit: + self.widget_bubbles.add_button( + command="", + label=" ", + new_row=False, + ) + else: + self.widget_bubbles.add_button( + command=f"{self.command} {self.RIGHT_ARROW}", + label=self.RIGHT_ARROW, + data={MONTH_TO_DISPLAY_KEY: next_month.isoformat()}, + new_row=False, + ) + + def add_week_bubbles(self) -> None: + for idx, weekday in enumerate(self.WEEKDAYS): + self.widget_bubbles.add_button( + command="", + label=weekday, + new_row=idx == 0, + ) + + def add_day_bubbles(self) -> None: + weeks = Calendar().monthdatescalendar( + year=self.current_date.year, + month=self.current_date.month, + ) + + for calendar_row, week in enumerate(weeks, 1): + row_data: list[tuple[str, str, dict[str, str]]] = [] + append_row = False + + for calendar_date in week: + show_day = not any( + ( + not self.include_past and calendar_date < self.start_date, + calendar_date > self.end_date, + calendar_row == len(weeks) and calendar_date.day < 7, + calendar_row == 1 and calendar_date.day > 7, + ) + ) + + if show_day: + label = str(calendar_date.day) + command = f"{self.command} {calendar_date.isoformat()}" + payload = {SELECTED_DATE_KEY: calendar_date.isoformat()} + append_row = True + else: + label = "" + command = "" + payload = {} + + row_data.append((command, label, payload)) + + if not append_row: + continue + + for button_idx, (command, label, payload) in enumerate(row_data): + self.widget_bubbles.add_button( + command=command, + label=label, + data=payload, + new_row=button_idx == 0, + ) + + +def _clear_calendar_data(message: IncomingMessage) -> None: + for metadata in (message.data, message.metadata): + metadata.pop(MONTH_TO_DISPLAY_KEY, None) + metadata.pop(SELECTED_DATE_KEY, None) diff --git a/pybotx/widgets/carousel.py b/pybotx/widgets/carousel.py new file mode 100644 index 00000000..18f65ba1 --- /dev/null +++ b/pybotx/widgets/carousel.py @@ -0,0 +1,267 @@ +from collections.abc import Iterator, Sequence +from itertools import cycle, islice +from typing import Any + +from pybotx.bot.bot import Bot +from pybotx.models.message.incoming_message import IncomingMessage + +from . import strings +from .base import Widget +from .markup import MessageMarkup +from .service import send_or_update_message + +LEFT_PRESSED = "CAROUSEL_LEFT_BUTTON_PRESSED" +RIGHT_PRESSED = "CAROUSEL_RIGHT_BUTTON_PRESSED" + +START_FROM_KEY = "carousel_start_from" +SELECTED_VALUE_KEY = "carousel_selected_val" +SELECTED_VALUE_LABEL_KEY = "carousel_selected_value_label" +MESSAGE_LABEL_KEY = "carousel_message_label" + + +def _to_int(value: object, default: int) -> int: + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return default + + +class CarouselWidget(Widget): + LEFT_ARROW = strings.LEFT_ARROW + RIGHT_ARROW = strings.RIGHT_ARROW + LEFT_LABEL_WITH_NUMBERS = f"{LEFT_ARROW} ({{}}-{{}})" + RIGHT_LABEL_WITH_NUMBERS = f"{RIGHT_ARROW} ({{}}-{{}})" + SELECTED_VALUE_LABEL = strings.SELECTED_VALUE_LABEL + + def __init__( + self, + widget_content: Sequence[Any], + label: str, + start_from: int = 0, + displayed_content_count: int = 3, + control_labels: tuple[str, str] | None = None, + inline: bool = True, + loop: bool = True, + show_numbers: bool = False, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.widget_content = widget_content + self.widget_message.body = label + self.displayed_content_count = displayed_content_count + self.inline = inline + self.loop = loop + self.show_numbers = show_numbers + + if control_labels is not None: + self._control_labels = control_labels + elif show_numbers: + self._control_labels = ( + self.LEFT_LABEL_WITH_NUMBERS, + self.RIGHT_LABEL_WITH_NUMBERS, + ) + else: + self._control_labels = (self.LEFT_ARROW, self.RIGHT_ARROW) + + self._start_from = _to_int(self.message.data.get(START_FROM_KEY), start_from) + self.selected_val = self.message.data.get(SELECTED_VALUE_KEY, "") + self.content_len = len(self.widget_content) + + self._validate_params(start_from) + + if self.selected_val == LEFT_PRESSED: + self._start_from -= displayed_content_count + elif self.selected_val == RIGHT_PRESSED: + self._start_from += displayed_content_count + + @classmethod + async def get_value( + cls, + message: IncomingMessage, + bot: Bot, + *, + send_feedback: bool = True, + ) -> str | None: + selected_val = message.data[SELECTED_VALUE_KEY] + if not selected_val or selected_val in {LEFT_PRESSED, RIGHT_PRESSED}: + return None + + label = str(message.data.get(MESSAGE_LABEL_KEY, "")) + selected_value_label = str( + message.data.get(SELECTED_VALUE_LABEL_KEY, cls.SELECTED_VALUE_LABEL) + ) + msg_text = selected_value_label.format( + label=label, + selected_val=selected_val, + ) + if send_feedback: + await send_or_update_message(message, bot, msg_text, MessageMarkup()) + _clear_carousel_data(message) + + return str(selected_val) + + @property + def start_from(self) -> int: + if self.content_len == 0: + return 0 + + if self._start_from < 0 or self._start_from > self.content_len: + return abs(self.content_len - abs(self._start_from)) + + return self._start_from + + @property + def end(self) -> int: + return self.start_from + self.displayed_content_count + + @property + def left_btn_label(self) -> str: + left_label = self._control_labels[0] + if self.show_numbers: + left_bound = self.start_from - self.displayed_content_count + 1 + left_label = left_label.format(left_bound, self.start_from) + return left_label + + @property + def right_btn_label(self) -> str: + right_label = self._control_labels[1] + if self.show_numbers: + right_bound = self.end + self.displayed_content_count + if self.content_len < right_bound: + right_bound = self.content_len + right_label = right_label.format(self.end + 1, right_bound) + return right_label + + @property + def displayed_content(self) -> Iterator[Any]: + if self.loop and self.widget_content: + return islice(cycle(self.widget_content), self.start_from, self.end) + return islice(self.widget_content, self.start_from, self.end) + + def add_markup(self) -> None: + if self.inline: + self.add_inline_markup() + else: + self.add_newline_markup() + + self.add_additional_markup() + + def add_inline_markup(self) -> None: + show_left_arrow, show_right_arrow = self.get_left_and_right_button_visibility() + + if show_left_arrow: + self.widget_bubbles.add_button( + command=self.command, + label=self.left_btn_label, + data=self._control_data(LEFT_PRESSED), + ) + + for idx, content_item in enumerate(self.displayed_content): + self.widget_bubbles.add_button( + command=self.command, + label=str(content_item), + data=self._item_data(content_item), + new_row=idx == 0 and not show_left_arrow, + ) + + if show_right_arrow: + self.widget_bubbles.add_button( + command=self.command, + label=self.right_btn_label, + data=self._control_data(RIGHT_PRESSED), + new_row=False, + ) + + def add_newline_markup(self) -> None: + show_left_arrow, show_right_arrow = self.get_left_and_right_button_visibility() + + for content_item in self.displayed_content: + if isinstance(content_item, (list, tuple, set)): + for idx, row_item in enumerate(content_item): + self.widget_bubbles.add_button( + command=self.command, + label=str(row_item), + data=self._item_data(row_item), + new_row=idx == 0, + ) + continue + + self.widget_bubbles.add_button( + command=self.command, + label=str(content_item), + data=self._item_data(content_item), + ) + + if show_left_arrow: + self.widget_bubbles.add_button( + command=self.command, + label=self.left_btn_label, + data=self._control_data(LEFT_PRESSED), + ) + + if show_right_arrow: + self.widget_bubbles.add_button( + command=self.command, + label=self.right_btn_label, + data=self._control_data(RIGHT_PRESSED), + new_row=not show_left_arrow, + ) + + def get_left_and_right_button_visibility(self) -> tuple[bool, bool]: + if len(self.widget_content) <= self.displayed_content_count: + return False, False + + if self.loop: + return True, True + + return self.start_from > 0, self.end < self.content_len + + def _item_data(self, selected_value: Any) -> dict[str, Any]: + return { + START_FROM_KEY: self.start_from, + SELECTED_VALUE_KEY: selected_value, + SELECTED_VALUE_LABEL_KEY: self.SELECTED_VALUE_LABEL, + MESSAGE_LABEL_KEY: self.widget_message.body, + } + + def _control_data(self, selected_value: str) -> dict[str, Any]: + return { + START_FROM_KEY: self.start_from, + SELECTED_VALUE_KEY: selected_value, + SELECTED_VALUE_LABEL_KEY: self.SELECTED_VALUE_LABEL, + MESSAGE_LABEL_KEY: self.widget_message.body, + } + + def _validate_params(self, start_from: int) -> None: + if "{selected_val}" not in self.SELECTED_VALUE_LABEL: + raise ValueError("'SELECTED_VALUE_LABEL' should contain '{selected_val}'") + + if start_from > len(self.widget_content): + raise ValueError("'start_from' is greater than 'widget_content'") + + if self.displayed_content_count <= 0: + raise ValueError("'displayed_content_count' should be greater than 0") + + if self.loop and self.show_numbers: + raise ValueError("You cannot enable both 'loop' and 'show_numbers'") + + if self.inline and self.show_numbers: + raise ValueError("You cannot enable both 'inline' and 'show_numbers'") + + if self.show_numbers: + if self._control_labels[0].count("{}") != 2: + raise ValueError("Left control label should have exactly two '{}'") + if self._control_labels[1].count("{}") != 2: + raise ValueError("Right control label should have exactly two '{}'") + + +def _clear_carousel_data(message: IncomingMessage) -> None: + for metadata in (message.data, message.metadata): + metadata.pop(SELECTED_VALUE_KEY, None) + metadata.pop(SELECTED_VALUE_LABEL_KEY, None) + metadata.pop(MESSAGE_LABEL_KEY, None) + metadata.pop(START_FROM_KEY, None) diff --git a/pybotx/widgets/checklist.py b/pybotx/widgets/checklist.py new file mode 100644 index 00000000..c674a409 --- /dev/null +++ b/pybotx/widgets/checklist.py @@ -0,0 +1,76 @@ +from collections.abc import Iterable, Sequence +from typing import Any + +from . import strings +from .base import Widget + +SELECTED_ITEM_KEY = "checklist_selected_item" +CHECKED_ITEMS_KEY = "checklist_checked_items" + + +class CheckListWidget(Widget): + def __init__( + self, + widget_content: Sequence[Any], + label: str, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.widget_content = widget_content + self.widget_message.body = label + + self.checked_items = list(self.message.metadata.get(CHECKED_ITEMS_KEY, [])) + self._toggle_checked_item() + + @classmethod + def get_value(cls, message: Any) -> Any: + return message.data[SELECTED_ITEM_KEY] + + @classmethod + def get_checked_items(cls, message: Any) -> list[Any]: + return list(message.metadata.get(CHECKED_ITEMS_KEY, [])) + + def add_markup(self) -> None: + for content_item in self.widget_content: + if isinstance(content_item, (list, tuple, set)): + self._add_row(content_item) + continue + self._add_button(content_item) + + self.add_additional_markup() + + def _toggle_checked_item(self) -> None: + if SELECTED_ITEM_KEY not in self.message.data: + return + + selected_item = self.message.data[SELECTED_ITEM_KEY] + if selected_item in self.checked_items: + self.checked_items.remove(selected_item) + else: + self.checked_items.append(selected_item) + + self.widget_metadata[CHECKED_ITEMS_KEY] = self.checked_items + + def _add_row(self, row: Iterable[Any]) -> None: + for row_idx, row_item in enumerate(row): + self.widget_bubbles.add_button( + command=self.command, + label=self._checkbox_label(row_item), + data={SELECTED_ITEM_KEY: row_item}, + new_row=row_idx == 0, + ) + + def _add_button(self, item: Any) -> None: + self.widget_bubbles.add_button( + command=self.command, + label=self._checkbox_label(item), + data={SELECTED_ITEM_KEY: item}, + ) + + def _checkbox_label(self, content_item: Any) -> str: + checkbox = strings.CHECKBOX_UNCHECKED + if content_item in self.checked_items: + checkbox = strings.CHECKBOX_CHECKED + + return f"{checkbox} {content_item}" diff --git a/pybotx/widgets/checktable.py b/pybotx/widgets/checktable.py new file mode 100644 index 00000000..1af6b373 --- /dev/null +++ b/pybotx/widgets/checktable.py @@ -0,0 +1,82 @@ +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, model_validator + +from . import strings +from .base import Widget +from .undefined import Undefined, undefined + +T = TypeVar("T") + + +class CheckboxContent(BaseModel, Generic[T]): + label: str + command: str + checkbox_value: T | Undefined | None = undefined + mapping: dict[T, str] | None = None + data: dict[str, Any] | None = None + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @model_validator(mode="after") + def validate_checkbox_value(self) -> "CheckboxContent[T]": + if ( + self.mapping + and not isinstance(self.checkbox_value, Undefined) + and self.checkbox_value not in self.mapping + ): + msg = f"'mapping' should contain 'checkbox_value' - '{self.checkbox_value}'" + raise ValueError(msg) + return self + + +class ChecktableWidget(Widget): + def __init__( + self, + checkboxes: list[CheckboxContent[Any]], + label: str, + uncheck_command: str, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.checkboxes = checkboxes + self.uncheck_command = uncheck_command + self.widget_message.body = label + + def add_markup(self) -> None: + for checkbox in self.checkboxes: + payload = checkbox.data or {} + checkbox_status = strings.CHECKBOX_UNCHECKED + + if not isinstance(checkbox.checkbox_value, Undefined): + checkbox_status = strings.CHECKBOX_CHECKED + + self.widget_bubbles.add_button( + command=self.uncheck_command, + label=f"{checkbox_status} {checkbox.label}", + data=payload, + ) + self.widget_bubbles.add_button( + command=checkbox.command, + label=self._checkbox_value_text(checkbox), + data=payload, + new_row=False, + ) + + self.add_additional_markup() + + def _checkbox_value_text(self, checkbox: CheckboxContent[Any]) -> str: + if checkbox.checkbox_value is None: + return strings.EMPTY + + is_undefined = isinstance(checkbox.checkbox_value, Undefined) + if checkbox.mapping: + if is_undefined: + return strings.CHOOSE_LABEL + return checkbox.mapping[checkbox.checkbox_value] + + if is_undefined: + return strings.FILL_LABEL + + return str(checkbox.checkbox_value) diff --git a/pybotx/widgets/confirm.py b/pybotx/widgets/confirm.py new file mode 100644 index 00000000..d33a2497 --- /dev/null +++ b/pybotx/widgets/confirm.py @@ -0,0 +1,54 @@ +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +CONFIRM_ACTION_KEY = "confirm_action" +CONFIRM_ACTION = "confirm" +CANCEL_ACTION = "cancel" + + +class ConfirmWidget(Widget): + def __init__( + self, + label: str, + confirm_label: str = "Подтвердить", + cancel_label: str = "Отмена", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.widget_message.body = label + self.confirm_label = confirm_label + self.cancel_label = cancel_label + + def add_markup(self) -> None: + self.widget_bubbles.add_button( + command=self.command, + label=self.confirm_label, + data={CONFIRM_ACTION_KEY: CONFIRM_ACTION}, + ) + self.widget_bubbles.add_button( + command=self.command, + label=self.cancel_label, + data={CONFIRM_ACTION_KEY: CANCEL_ACTION}, + new_row=False, + ) + self.add_additional_markup() + + @classmethod + def get_action(cls, message: IncomingMessage) -> str: + action = message.data.get(CONFIRM_ACTION_KEY) + if action not in {CONFIRM_ACTION, CANCEL_ACTION}: + raise RuntimeError("Confirmation action is not found.") + + return str(action) + + @classmethod + def is_confirmed(cls, message: IncomingMessage) -> bool: + return cls.get_action(message) == CONFIRM_ACTION + + @classmethod + def is_cancelled(cls, message: IncomingMessage) -> bool: + return cls.get_action(message) == CANCEL_ACTION diff --git a/pybotx/widgets/date_range.py b/pybotx/widgets/date_range.py new file mode 100644 index 00000000..c45855b5 --- /dev/null +++ b/pybotx/widgets/date_range.py @@ -0,0 +1,137 @@ +from datetime import date, timedelta +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +DATE_RANGE_START_KEY = "date_range_start" +DATE_RANGE_END_KEY = "date_range_end" +DATE_RANGE_CURSOR_KEY = "date_range_cursor" +DATE_RANGE_SELECTED_DATE_KEY = "date_range_selected_date" +DATE_RANGE_ACTION_KEY = "date_range_action" +DATE_RANGE_ACTION_RESET = "reset" + + +def _parse_iso_date(value: object) -> date | None: + if isinstance(value, date): + return value + + if not isinstance(value, str): + return None + + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _serialize_date(value: date | None) -> str | None: + if value is None: + return None + return value.isoformat() + + +class DateRangeWidget(Widget): + def __init__( + self, + label: str = "Выбор периода", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.label = label + self.start = _parse_iso_date(self.message.metadata.get(DATE_RANGE_START_KEY)) + self.end = _parse_iso_date(self.message.metadata.get(DATE_RANGE_END_KEY)) + self.cursor = str(self.message.metadata.get(DATE_RANGE_CURSOR_KEY, "start")) + self._apply_action() + self._apply_selected_date() + self._sync_metadata() + self.widget_message.body = self._build_body() + + def add_markup(self) -> None: + if self.cursor == "start": + self._add_start_buttons() + self.add_additional_markup() + return + + if self.cursor == "end" and self.start is not None: + for days in (1, 7, 30): + suggested_date = self.start + timedelta(days=days) + self.widget_bubbles.add_button( + command=self.command, + label=f"+{days} дн.", + data={DATE_RANGE_SELECTED_DATE_KEY: suggested_date.isoformat()}, + new_row=days == 1, + ) + self._add_reset_button(new_row=True) + self.add_additional_markup() + return + + self._add_reset_button() + self.add_additional_markup() + + @classmethod + def get_value(cls, message: IncomingMessage) -> tuple[date, date]: + start = _parse_iso_date(message.metadata.get(DATE_RANGE_START_KEY)) + end = _parse_iso_date(message.metadata.get(DATE_RANGE_END_KEY)) + if start is None or end is None: + raise RuntimeError("Date range is incomplete.") + + return start, end + + def _apply_action(self) -> None: + if self.message.data.get(DATE_RANGE_ACTION_KEY) != DATE_RANGE_ACTION_RESET: + return + + self.start = None + self.end = None + self.cursor = "start" + + def _apply_selected_date(self) -> None: + selected_date = _parse_iso_date(self.message.data.get(DATE_RANGE_SELECTED_DATE_KEY)) + if selected_date is None: + return + + if self.start is None or self.cursor == "start": + self.start = selected_date + self.end = None + self.cursor = "end" + return + + self.end = selected_date + if self.end < self.start: + self.start, self.end = self.end, self.start + self.cursor = "done" + + def _sync_metadata(self) -> None: + self.widget_metadata[DATE_RANGE_START_KEY] = _serialize_date(self.start) + self.widget_metadata[DATE_RANGE_END_KEY] = _serialize_date(self.end) + self.widget_metadata[DATE_RANGE_CURSOR_KEY] = self.cursor + + def _build_body(self) -> str: + start = _serialize_date(self.start) or "—" + end = _serialize_date(self.end) or "—" + return f"{self.label}\nНачало: {start}\nКонец: {end}" + + def _add_start_buttons(self) -> None: + today = date.today() + self.widget_bubbles.add_button( + command=self.command, + label="Сегодня", + data={DATE_RANGE_SELECTED_DATE_KEY: today.isoformat()}, + ) + self.widget_bubbles.add_button( + command=self.command, + label="Завтра", + data={DATE_RANGE_SELECTED_DATE_KEY: (today + timedelta(days=1)).isoformat()}, + new_row=False, + ) + + def _add_reset_button(self, *, new_row: bool = False) -> None: + self.widget_bubbles.add_button( + command=self.command, + label="Сбросить", + data={DATE_RANGE_ACTION_KEY: DATE_RANGE_ACTION_RESET}, + new_row=new_row, + ) diff --git a/pybotx/widgets/factory.py b/pybotx/widgets/factory.py new file mode 100644 index 00000000..f03f463b --- /dev/null +++ b/pybotx/widgets/factory.py @@ -0,0 +1,464 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import date +from typing import Any + +from pybotx.bot.bot import Bot +from pybotx.models.message.incoming_message import IncomingMessage +from pybotx.models.message.outgoing_message import OutgoingMessage + +from .approval import ApprovalWidget +from .async_job import AsyncJobWidget +from .base import WidgetResultMode +from .calendar import CalendarWidget +from .carousel import CarouselWidget +from .checklist import CheckListWidget +from .checktable import CheckboxContent, ChecktableWidget +from .confirm import ConfirmWidget +from .date_range import DateRangeWidget +from .file_batch import BatchFileItem, FileBatchWidget +from .form_wizard import FormWizardStep, FormWizardWidget +from .markup import MessageMarkup +from .messages_pager import MessagesPagerWidget +from .multi_select import MultiSelectWidget +from .pagination import PaginationWidget +from .range import RangeWidget +from .search_select import SearchSelectWidget +from .select import SelectOption, SelectWidget +from .table import TableWidget + + +@dataclass(frozen=True, slots=True) +class WidgetContext: + message: IncomingMessage + bot: Bot + command: str + additional_markup: MessageMarkup | None = None + result_mode: WidgetResultMode | None = None + + def as_widget_kwargs(self) -> dict[str, object]: + kwargs: dict[str, object] = { + "message": self.message, + "bot": self.bot, + "command": self.command, + } + if self.additional_markup is not None: + kwargs["additional_markup"] = self.additional_markup + if self.result_mode is not None: + kwargs["result_mode"] = self.result_mode + return kwargs + + +@dataclass(frozen=True, slots=True) +class WidgetDefaults: + confirm_label: str = "Подтвердить" + cancel_label: str = "Отмена" + approve_label: str = "Согласовать" + reject_label: str = "Отклонить" + comment_label: str = "Комментарий" + search_empty_label: str = "Ничего не найдено" + selected_prefix: str = "• " + + +class WidgetFactory: + def __init__( + self, + *, + context: WidgetContext, + defaults: WidgetDefaults | None = None, + ) -> None: + self._context = context + self._defaults = defaults or WidgetDefaults() + + def confirm( + self, + *, + label: str, + confirm_label: str | None = None, + cancel_label: str | None = None, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> ConfirmWidget: + return ConfirmWidget( + label=label, + confirm_label=confirm_label or self._defaults.confirm_label, + cancel_label=cancel_label or self._defaults.cancel_label, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def approval( + self, + *, + label: str, + approve_label: str | None = None, + reject_label: str | None = None, + comment_label: str | None = None, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> ApprovalWidget: + return ApprovalWidget( + label=label, + approve_label=approve_label or self._defaults.approve_label, + reject_label=reject_label or self._defaults.reject_label, + comment_label=comment_label or self._defaults.comment_label, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def select( + self, + *, + options: Sequence[str | SelectOption], + label: str, + selected_prefix: str | None = None, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> SelectWidget: + return SelectWidget( + options=list(options), + label=label, + selected_prefix=selected_prefix or self._defaults.selected_prefix, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def search_select( + self, + *, + options: Sequence[str | SelectOption], + label: str, + page_size: int = 5, + empty_label: str | None = None, + selected_prefix: str | None = None, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> SearchSelectWidget: + return SearchSelectWidget( + options=list(options), + label=label, + page_size=page_size, + empty_label=empty_label or self._defaults.search_empty_label, + selected_prefix=selected_prefix or self._defaults.selected_prefix, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def multi_select( + self, + *, + options: Sequence[str | SelectOption], + label: str, + max_selected: int | None = None, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> MultiSelectWidget: + return MultiSelectWidget( + options=list(options), + label=label, + max_selected=max_selected, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def form_wizard( + self, + *, + steps: Sequence[FormWizardStep], + label: str, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> FormWizardWidget: + return FormWizardWidget( + steps=list(steps), + label=label, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def table( + self, + *, + rows: Sequence[dict[str, Any]], + columns: Sequence[str], + label: str = "Таблица", + page_size: int = 5, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> TableWidget: + return TableWidget( + rows=list(rows), + columns=list(columns), + label=label, + page_size=page_size, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def calendar( + self, + *, + start_date: date | None = None, + end_date: date = date.max, + include_past: bool = False, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> CalendarWidget: + return CalendarWidget( + start_date=start_date, + end_date=end_date, + include_past=include_past, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def range( + self, + *, + elements: Sequence[str] | None = None, + label_template: str = "Current element: {element}", + forward_label: str = "Вперед", + backward_label: str = "Назад", + empty_body: str = "Список пуст", + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> RangeWidget: + return RangeWidget( + elements=elements, + label_template=label_template, + forward_label=forward_label, + backward_label=backward_label, + empty_body=empty_body, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def carousel( + self, + *, + widget_content: Sequence[Any], + label: str, + start_from: int = 0, + displayed_content_count: int = 3, + control_labels: tuple[str, str] | None = None, + inline: bool = True, + loop: bool = True, + show_numbers: bool = False, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> CarouselWidget: + return CarouselWidget( + widget_content=widget_content, + label=label, + start_from=start_from, + displayed_content_count=displayed_content_count, + control_labels=control_labels, + inline=inline, + loop=loop, + show_numbers=show_numbers, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def pagination( + self, + *, + widget_content: Sequence[OutgoingMessage | str], + paginate_by: int, + delay_between_messages: float = 0.5, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> PaginationWidget: + return PaginationWidget( + widget_content=list(widget_content), + paginate_by=paginate_by, + delay_between_messages=delay_between_messages, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def messages_pager( + self, + *, + elements: Sequence[str], + page_size: int, + item_template: str = "Element: {element}", + delay_between_messages: float = 0.5, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> MessagesPagerWidget: + return MessagesPagerWidget( + elements=elements, + page_size=page_size, + item_template=item_template, + delay_between_messages=delay_between_messages, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def date_range( + self, + *, + label: str = "Выбор периода", + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> DateRangeWidget: + return DateRangeWidget( + label=label, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def async_job( + self, + *, + job_id: str, + status: str, + details: str = "", + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> AsyncJobWidget: + return AsyncJobWidget( + job_id=job_id, + status=status, + details=details, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def file_batch( + self, + *, + files: Sequence[BatchFileItem | dict[str, Any] | str] | None, + label: str = "Пакет файлов", + page_size: int = 5, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> FileBatchWidget: + normalized_files = None if files is None else list(files) + return FileBatchWidget( + files=normalized_files, + label=label, + page_size=page_size, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def checklist( + self, + *, + widget_content: Sequence[Any], + label: str, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> CheckListWidget: + return CheckListWidget( + widget_content=widget_content, + label=label, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def checktable( + self, + *, + checkboxes: Sequence[CheckboxContent[Any]], + label: str, + uncheck_command: str, + command: str | None = None, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> ChecktableWidget: + return ChecktableWidget( + checkboxes=list(checkboxes), + label=label, + uncheck_command=uncheck_command, + message=self._context.message, + bot=self._context.bot, + command=self._resolve_command(command), + additional_markup=self._resolve_additional_markup(additional_markup), + result_mode=self._resolve_result_mode(result_mode), + ) + + def _resolve_command(self, command: str | None) -> str: + return command or self._context.command + + def _resolve_additional_markup( + self, + additional_markup: MessageMarkup | None, + ) -> MessageMarkup | None: + if additional_markup is not None: + return additional_markup + return self._context.additional_markup + + def _resolve_result_mode( + self, + result_mode: WidgetResultMode | None, + ) -> WidgetResultMode | None: + if result_mode is not None: + return result_mode + return self._context.result_mode diff --git a/pybotx/widgets/file_batch.py b/pybotx/widgets/file_batch.py new file mode 100644 index 00000000..df80fcd6 --- /dev/null +++ b/pybotx/widgets/file_batch.py @@ -0,0 +1,198 @@ +from dataclasses import dataclass +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +FILE_BATCH_FILES_KEY = "file_batch_files" +FILE_BATCH_PAGE_KEY = "file_batch_page" +FILE_BATCH_ACTION_KEY = "file_batch_action" +FILE_BATCH_FILE_NAME_KEY = "file_batch_file_name" + +FILE_BATCH_ACTION_PREV = "prev" +FILE_BATCH_ACTION_NEXT = "next" +FILE_BATCH_ACTION_RETRY_FAILED = "retry_failed" +FILE_BATCH_ACTION_ITEM = "item" + + +@dataclass(frozen=True, slots=True) +class BatchFileItem: + name: str + status: str + error: str = "" + + +def _safe_int(value: object) -> int: + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return 0 + + +def _normalize_item(raw_item: object) -> BatchFileItem | None: + if isinstance(raw_item, BatchFileItem): + return raw_item + + if isinstance(raw_item, dict): + name = raw_item.get("name") + status = raw_item.get("status") + if name is None or status is None: + return None + + return BatchFileItem( + name=str(name), + status=str(status), + error=str(raw_item.get("error", "")), + ) + + if isinstance(raw_item, str): + return BatchFileItem(name=raw_item, status="pending") + + return None + + +def _status_icon(status: str) -> str: + if status in {"done", "ok", "success"}: + return "✅" + if status in {"failed", "error"}: + return "❌" + if status in {"running", "processing"}: + return "⏳" + return "•" + + +class FileBatchWidget(Widget): + def __init__( + self, + files: list[BatchFileItem | dict[str, Any] | str] | None, + label: str = "Пакет файлов", + page_size: int = 5, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.label = label + self.page_size = max(page_size, 1) + + raw_files = files + if raw_files is None: + metadata_files = self.message.metadata.get(FILE_BATCH_FILES_KEY, []) + if isinstance(metadata_files, list): + raw_files = metadata_files + else: + raw_files = [] + + self.files = [item for raw_item in raw_files if (item := _normalize_item(raw_item))] + self.page = _safe_int( + self.message.data.get( + FILE_BATCH_PAGE_KEY, + self.message.metadata.get(FILE_BATCH_PAGE_KEY, 0), + ), + ) + self.page = min(max(self.page, 0), self._max_page()) + self.displayed_files = self._slice_files() + + self.widget_message.body = self._build_body() + self._sync_metadata() + + def add_markup(self) -> None: + for file_item in self.displayed_files: + self.widget_bubbles.add_button( + command=self.command, + label=file_item.name, + data={ + FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_ITEM, + FILE_BATCH_FILE_NAME_KEY: file_item.name, + }, + ) + + if self.page > 0: + self.widget_bubbles.add_button( + command=self.command, + label="Назад", + data={ + FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_PREV, + FILE_BATCH_PAGE_KEY: self.page - 1, + }, + ) + + if self.page < self._max_page(): + self.widget_bubbles.add_button( + command=self.command, + label="Вперед", + data={ + FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_NEXT, + FILE_BATCH_PAGE_KEY: self.page + 1, + }, + new_row=self.page == 0, + ) + + if self._failed_count() > 0: + self.widget_bubbles.add_button( + command=self.command, + label="Повторить ошибки", + data={FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_RETRY_FAILED}, + ) + + self.add_additional_markup() + + @classmethod + def get_action(cls, message: IncomingMessage) -> str: + action = message.data.get(FILE_BATCH_ACTION_KEY) + if action not in { + FILE_BATCH_ACTION_PREV, + FILE_BATCH_ACTION_NEXT, + FILE_BATCH_ACTION_RETRY_FAILED, + FILE_BATCH_ACTION_ITEM, + }: + raise RuntimeError("File batch action is not found.") + + return str(action) + + @classmethod + def get_selected_file(cls, message: IncomingMessage) -> str: + file_name = message.data.get(FILE_BATCH_FILE_NAME_KEY) + if file_name is None: + raise RuntimeError("Selected file name is not found.") + + return str(file_name) + + def _max_page(self) -> int: + if not self.files: + return 0 + return (len(self.files) - 1) // self.page_size + + def _slice_files(self) -> list[BatchFileItem]: + start = self.page * self.page_size + end = start + self.page_size + return self.files[start:end] + + def _failed_count(self) -> int: + return sum(file_item.status in {"failed", "error"} for file_item in self.files) + + def _build_body(self) -> str: + lines = [ + self.label, + f"Всего: {len(self.files)}", + f"Ошибок: {self._failed_count()}", + ] + for file_item in self.displayed_files: + lines.append(f"{_status_icon(file_item.status)} {file_item.name}") + if file_item.error: + lines.append(f" └ {file_item.error}") + return "\n".join(lines) + + def _sync_metadata(self) -> None: + self.widget_metadata[FILE_BATCH_FILES_KEY] = [ + { + "name": file_item.name, + "status": file_item.status, + "error": file_item.error, + } + for file_item in self.files + ] + self.widget_metadata[FILE_BATCH_PAGE_KEY] = self.page diff --git a/pybotx/widgets/form_wizard.py b/pybotx/widgets/form_wizard.py new file mode 100644 index 00000000..ff54a46c --- /dev/null +++ b/pybotx/widgets/form_wizard.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +FORM_STEP_INDEX_KEY = "form_step_index" +FORM_VALUES_KEY = "form_values" +FORM_ACTION_KEY = "form_action" +FORM_INPUT_KEY = "form_input" + +FORM_ACTION_NEXT = "next" +FORM_ACTION_BACK = "back" +FORM_ACTION_CANCEL = "cancel" +FORM_ACTION_RESET = "reset" + + +@dataclass(frozen=True, slots=True) +class FormWizardStep: + field: str + label: str + + +def _safe_index(value: object, default: int = 0) -> int: + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return default + + +class FormWizardWidget(Widget): + def __init__( + self, + steps: list[FormWizardStep], + label: str, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.steps = steps + self.label = label + self.cancelled = False + + values = self.message.metadata.get(FORM_VALUES_KEY, {}) + self.values = values if isinstance(values, dict) else {} + self.step_index = _safe_index(self.message.metadata.get(FORM_STEP_INDEX_KEY)) + self.step_index = min(max(self.step_index, 0), len(self.steps)) + + self._apply_input() + self._apply_action() + self._update_metadata() + self.widget_message.body = self._build_body() + + @property + def is_completed(self) -> bool: + return self.step_index >= len(self.steps) and not self.cancelled + + @property + def is_active(self) -> bool: + return not self.cancelled and not self.is_completed + + def add_markup(self) -> None: + if self.cancelled or self.is_completed: + self.widget_bubbles.add_button( + command=self.command, + label="Сбросить", + data={FORM_ACTION_KEY: FORM_ACTION_RESET}, + ) + self.add_additional_markup() + return + + current_step = self.steps[self.step_index] + if self.step_index > 0: + self.widget_bubbles.add_button( + command=self.command, + label="Назад", + data={FORM_ACTION_KEY: FORM_ACTION_BACK}, + ) + + self.widget_bubbles.add_button( + command=self.command, + label="Далее", + data={FORM_ACTION_KEY: FORM_ACTION_NEXT}, + new_row=self.step_index == 0, + ) + self.widget_bubbles.add_button( + command=self.command, + label="Отмена", + data={FORM_ACTION_KEY: FORM_ACTION_CANCEL}, + new_row=False, + ) + self.widget_bubbles.add_button( + command=self.command, + label="Использовать текст сообщения", + data={ + FORM_ACTION_KEY: FORM_ACTION_NEXT, + FORM_INPUT_KEY: self.message.argument, + "form_field": current_step.field, + }, + ) + self.add_additional_markup() + + def _apply_input(self) -> None: + if self.step_index >= len(self.steps): + return + + if FORM_INPUT_KEY not in self.message.data: + return + + current_step = self.steps[self.step_index] + self.values[current_step.field] = str(self.message.data.get(FORM_INPUT_KEY, "")) + + def _apply_action(self) -> None: + action = self.message.data.get(FORM_ACTION_KEY) + if action == FORM_ACTION_BACK: + self.step_index = max(self.step_index - 1, 0) + return + + if action == FORM_ACTION_NEXT: + self.step_index = min(self.step_index + 1, len(self.steps)) + return + + if action == FORM_ACTION_CANCEL: + self.cancelled = True + return + + if action == FORM_ACTION_RESET: + self.cancelled = False + self.step_index = 0 + self.values = {} + + def _update_metadata(self) -> None: + self.widget_metadata[FORM_VALUES_KEY] = self.values + self.widget_metadata[FORM_STEP_INDEX_KEY] = self.step_index + self.widget_metadata["form_cancelled"] = self.cancelled + + def _build_body(self) -> str: + if self.cancelled: + return f"{self.label}\nОперация отменена." + + if self.is_completed: + return f"{self.label}\nФорма заполнена." + + current_step = self.steps[self.step_index] + current_value = self.values.get(current_step.field, "—") + step_no = self.step_index + 1 + total = len(self.steps) + return ( + f"{self.label}\n" + f"Шаг {step_no}/{total}: {current_step.label}\n" + f"Текущее значение: {current_value}" + ) + + @classmethod + def get_values(cls, message: IncomingMessage) -> dict[str, Any]: + values = message.metadata.get(FORM_VALUES_KEY, {}) + if not isinstance(values, dict): + raise RuntimeError("Form values are not found in metadata.") + return values diff --git a/pybotx/widgets/markup.py b/pybotx/widgets/markup.py new file mode 100644 index 00000000..d78faefc --- /dev/null +++ b/pybotx/widgets/markup.py @@ -0,0 +1,102 @@ +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from pybotx.missing import Missing, Undefined +from pybotx.models.message.markup import ( + BubbleMarkup, + ButtonTextAlign, + KeyboardMarkup, +) + +TMarkup = TypeVar("TMarkup", BubbleMarkup, KeyboardMarkup) + + +@dataclass(slots=True) +class MessageMarkup: + bubbles: BubbleMarkup = field(default_factory=BubbleMarkup) + keyboard: KeyboardMarkup = field(default_factory=KeyboardMarkup) + + def add_bubble( + self, + *, + label: str, + command: Missing[str] = Undefined, + data: dict[str, Any] | None = None, + text_color: Missing[str] = Undefined, + background_color: Missing[str] = Undefined, + align: ButtonTextAlign = ButtonTextAlign.CENTER, + silent: bool = True, + width_ratio: Missing[int] = Undefined, + alert: Missing[str] = Undefined, + process_on_client: Missing[bool] = Undefined, + link: Missing[str] = Undefined, + new_row: bool = True, + ) -> None: + self.bubbles.add_button( + label=label, + command=command, + data=data, + text_color=text_color, + background_color=background_color, + align=align, + silent=silent, + width_ratio=width_ratio, + alert=alert, + process_on_client=process_on_client, + link=link, + new_row=new_row, + ) + + def add_keyboard( + self, + *, + label: str, + command: Missing[str] = Undefined, + data: dict[str, Any] | None = None, + text_color: Missing[str] = Undefined, + background_color: Missing[str] = Undefined, + align: ButtonTextAlign = ButtonTextAlign.CENTER, + silent: bool = True, + width_ratio: Missing[int] = Undefined, + alert: Missing[str] = Undefined, + process_on_client: Missing[bool] = Undefined, + link: Missing[str] = Undefined, + new_row: bool = True, + ) -> None: + self.keyboard.add_button( + label=label, + command=command, + data=data, + text_color=text_color, + background_color=background_color, + align=align, + silent=silent, + width_ratio=width_ratio, + alert=alert, + process_on_client=process_on_client, + link=link, + new_row=new_row, + ) + + +def clone_markup(markup: TMarkup) -> TMarkup: + cloned_markup = type(markup)() + for row in markup: + cloned_markup.add_row([deepcopy(button) for button in row]) + return cloned_markup + + +def merge_message_markup( + primary: MessageMarkup, + additional: MessageMarkup, +) -> MessageMarkup: + merged_bubbles = clone_markup(primary.bubbles) + for row in additional.bubbles: + merged_bubbles.add_row([deepcopy(button) for button in row]) + + merged_keyboard = clone_markup(primary.keyboard) + for row in additional.keyboard: + merged_keyboard.add_row([deepcopy(button) for button in row]) + + return MessageMarkup(bubbles=merged_bubbles, keyboard=merged_keyboard) diff --git a/pybotx/widgets/messages_pager.py b/pybotx/widgets/messages_pager.py new file mode 100644 index 00000000..59db4025 --- /dev/null +++ b/pybotx/widgets/messages_pager.py @@ -0,0 +1,44 @@ +from collections.abc import Sequence +from typing import cast + +from pybotx.bot.bot import Bot +from pybotx.models.message.incoming_message import IncomingMessage +from pybotx.models.message.outgoing_message import OutgoingMessage + +from .base import WidgetResultMode +from .markup import MessageMarkup +from .pagination import PaginationWidget + + +class MessagesPagerWidget(PaginationWidget): + def __init__( + self, + elements: Sequence[str], + page_size: int, + item_template: str = "Element: {element}", + delay_between_messages: float = 0.5, + *, + message: IncomingMessage, + bot: Bot, + command: str, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> None: + widget_content = [ + item_template.format( + element=element, + index=index + 1, + total=len(elements), + ) + for index, element in enumerate(elements) + ] + super().__init__( + widget_content=cast(list[OutgoingMessage | str], widget_content), + paginate_by=page_size, + delay_between_messages=delay_between_messages, + message=message, + bot=bot, + command=command, + additional_markup=additional_markup, + result_mode=result_mode, + ) diff --git a/pybotx/widgets/multi_select.py b/pybotx/widgets/multi_select.py new file mode 100644 index 00000000..5055ee37 --- /dev/null +++ b/pybotx/widgets/multi_select.py @@ -0,0 +1,71 @@ +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget +from .select import SelectOption, _normalize_option +from .strings import CHECKBOX_CHECKED, CHECKBOX_UNCHECKED + +MULTI_SELECT_VALUE_KEY = "multi_select_value" +MULTI_SELECTED_VALUES_KEY = "multi_selected_values" + + +def _coerce_str_list(raw_values: object) -> list[str]: + if not isinstance(raw_values, list): + return [] + + return [str(value) for value in raw_values] + + +class MultiSelectWidget(Widget): + def __init__( + self, + options: list[str | SelectOption], + label: str, + max_selected: int | None = None, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.options = [_normalize_option(option) for option in options] + self.max_selected = max_selected + self.widget_message.body = label + + self.selected_values = _coerce_str_list( + self.message.metadata.get(MULTI_SELECTED_VALUES_KEY), + ) + self._toggle_value_from_data() + self.widget_metadata[MULTI_SELECTED_VALUES_KEY] = self.selected_values + + def add_markup(self) -> None: + for option in self.options: + checkbox = CHECKBOX_UNCHECKED + if option.value in self.selected_values: + checkbox = CHECKBOX_CHECKED + + self.widget_bubbles.add_button( + command=self.command, + label=f"{checkbox} {option.label}", + data={MULTI_SELECT_VALUE_KEY: option.value}, + ) + + self.add_additional_markup() + + def _toggle_value_from_data(self) -> None: + clicked_value = self.message.data.get(MULTI_SELECT_VALUE_KEY) + if clicked_value is None: + return + + clicked_str = str(clicked_value) + if clicked_str in self.selected_values: + self.selected_values.remove(clicked_str) + return + + if self.max_selected is not None and len(self.selected_values) >= self.max_selected: + return + + self.selected_values.append(clicked_str) + + @classmethod + def get_selected_values(cls, message: IncomingMessage) -> list[str]: + return _coerce_str_list(message.metadata.get(MULTI_SELECTED_VALUES_KEY)) diff --git a/pybotx/widgets/observability.py b/pybotx/widgets/observability.py new file mode 100644 index 00000000..a162db54 --- /dev/null +++ b/pybotx/widgets/observability.py @@ -0,0 +1,262 @@ +import importlib +from collections.abc import Sequence +from dataclasses import dataclass +from threading import Lock +from typing import Any, TypeAlias + +from pybotx.widgets.runner import ( + WidgetRunnerFinishedEvent, + WidgetRunnerObserver, + WidgetRunnerStartedEvent, +) + +RunOutcomeKey: TypeAlias = tuple[str, str, str, str] +ErrorKey: TypeAlias = tuple[str, str, str] +SkippedKey: TypeAlias = tuple[str, str, str] +ResultMessagesKey: TypeAlias = tuple[str, str, str] + + +class NoopWidgetRunnerMetricsCollector(WidgetRunnerObserver): + def on_started(self, event: WidgetRunnerStartedEvent) -> None: + return None + + def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + return None + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerMetricsSnapshot: + in_flight: int + runs_total: int + errors_total: int + skipped_total: int + before_results_sent_total: int + after_results_sent_total: int + latency_sum_seconds: float + run_outcomes: dict[RunOutcomeKey, int] + errors: dict[ErrorKey, int] + skipped: dict[SkippedKey, int] + result_messages: dict[ResultMessagesKey, int] + + +class InMemoryWidgetRunnerMetricsCollector(WidgetRunnerObserver): + def __init__(self) -> None: + self._lock = Lock() + self._in_flight = 0 + self._runs_total = 0 + self._errors_total = 0 + self._skipped_total = 0 + self._before_results_sent_total = 0 + self._after_results_sent_total = 0 + self._latency_sum_seconds = 0.0 + self._run_outcomes: dict[RunOutcomeKey, int] = {} + self._errors: dict[ErrorKey, int] = {} + self._skipped: dict[SkippedKey, int] = {} + self._result_messages: dict[ResultMessagesKey, int] = {} + + def on_started(self, event: WidgetRunnerStartedEvent) -> None: + with self._lock: + self._in_flight += 1 + + def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + with self._lock: + self._in_flight = max(self._in_flight - 1, 0) + self._runs_total += 1 + self._latency_sum_seconds += max(event.duration_seconds, 0.0) + self._before_results_sent_total += max(event.before_results_sent, 0) + self._after_results_sent_total += max(event.after_results_sent, 0) + + outcome = "ok" if event.error is None else "error" + outcome_key = ( + event.widget_name, + event.command, + event.result_mode, + outcome, + ) + self._run_outcomes[outcome_key] = self._run_outcomes.get(outcome_key, 0) + 1 + + if event.before_results_sent > 0: + before_key = ( + event.widget_name, + event.command, + "before", + ) + self._result_messages[before_key] = ( + self._result_messages.get(before_key, 0) + event.before_results_sent + ) + + if event.after_results_sent > 0: + after_key = ( + event.widget_name, + event.command, + "after", + ) + self._result_messages[after_key] = ( + self._result_messages.get(after_key, 0) + event.after_results_sent + ) + + if event.skipped_by_before: + self._skipped_total += 1 + skipped_key = ( + event.widget_name, + event.command, + event.result_mode, + ) + self._skipped[skipped_key] = self._skipped.get(skipped_key, 0) + 1 + + if event.error is not None: + self._errors_total += 1 + error_key = ( + event.widget_name, + event.command, + type(event.error).__name__, + ) + self._errors[error_key] = self._errors.get(error_key, 0) + 1 + + def snapshot(self) -> WidgetRunnerMetricsSnapshot: + with self._lock: + return WidgetRunnerMetricsSnapshot( + in_flight=self._in_flight, + runs_total=self._runs_total, + errors_total=self._errors_total, + skipped_total=self._skipped_total, + before_results_sent_total=self._before_results_sent_total, + after_results_sent_total=self._after_results_sent_total, + latency_sum_seconds=self._latency_sum_seconds, + run_outcomes=dict(self._run_outcomes), + errors=dict(self._errors), + skipped=dict(self._skipped), + result_messages=dict(self._result_messages), + ) + + +class PrometheusWidgetRunnerMetricsCollector(WidgetRunnerObserver): + DEFAULT_LATENCY_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1.0, + 2.5, + 5.0, + 10.0, + ) + + def __init__( + self, + *, + registry: Any | None = None, + metric_prefix: str = "pybotx_widget", + latency_buckets: Sequence[float] | None = None, + ) -> None: + try: + prometheus_client = importlib.import_module("prometheus_client") + except ModuleNotFoundError as exc: + raise RuntimeError( + "PrometheusWidgetRunnerMetricsCollector requires `prometheus-client`. " + "Install with `uv add prometheus-client`.", + ) from exc + + counter_cls = getattr(prometheus_client, "Counter") + gauge_cls = getattr(prometheus_client, "Gauge") + histogram_cls = getattr(prometheus_client, "Histogram") + buckets = ( + tuple(latency_buckets) + if latency_buckets is not None + else self.DEFAULT_LATENCY_BUCKETS + ) + + self._runs_total = counter_cls( + f"{metric_prefix}_runs_total", + "Total number of widget runner executions.", + ("widget_name", "command", "result_mode", "outcome"), + registry=registry, + ) + self._errors_total = counter_cls( + f"{metric_prefix}_errors_total", + "Total number of failed widget runner executions.", + ("widget_name", "command", "error_type"), + registry=registry, + ) + self._skipped_total = counter_cls( + f"{metric_prefix}_skipped_total", + "Total number of widget runs skipped by before-hooks.", + ("widget_name", "command", "result_mode"), + registry=registry, + ) + self._result_messages_total = counter_cls( + f"{metric_prefix}_result_messages_total", + "Total number of result messages sent by widget runner hooks.", + ("widget_name", "command", "phase"), + registry=registry, + ) + self._latency_seconds = histogram_cls( + f"{metric_prefix}_latency_seconds", + "Widget runner latency in seconds.", + ("widget_name", "command", "result_mode", "outcome"), + buckets=buckets, + registry=registry, + ) + self._in_flight = gauge_cls( + f"{metric_prefix}_in_flight", + "Current number of widget runs in flight.", + ("widget_name", "command"), + registry=registry, + ) + + def on_started(self, event: WidgetRunnerStartedEvent) -> None: + self._in_flight.labels( + widget_name=event.widget_name, + command=event.command, + ).inc() + + def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + self._in_flight.labels( + widget_name=event.widget_name, + command=event.command, + ).dec() + + outcome = "ok" if event.error is None else "error" + self._runs_total.labels( + widget_name=event.widget_name, + command=event.command, + result_mode=event.result_mode, + outcome=outcome, + ).inc() + self._latency_seconds.labels( + widget_name=event.widget_name, + command=event.command, + result_mode=event.result_mode, + outcome=outcome, + ).observe(max(event.duration_seconds, 0.0)) + + if event.before_results_sent > 0: + self._result_messages_total.labels( + widget_name=event.widget_name, + command=event.command, + phase="before", + ).inc(event.before_results_sent) + + if event.after_results_sent > 0: + self._result_messages_total.labels( + widget_name=event.widget_name, + command=event.command, + phase="after", + ).inc(event.after_results_sent) + + if event.skipped_by_before: + self._skipped_total.labels( + widget_name=event.widget_name, + command=event.command, + result_mode=event.result_mode, + ).inc() + + if event.error is not None: + self._errors_total.labels( + widget_name=event.widget_name, + command=event.command, + error_type=type(event.error).__name__, + ).inc() diff --git a/pybotx/widgets/pagination.py b/pybotx/widgets/pagination.py new file mode 100644 index 00000000..c38c6dc9 --- /dev/null +++ b/pybotx/widgets/pagination.py @@ -0,0 +1,224 @@ +import asyncio +from copy import deepcopy +from typing import Any +from uuid import UUID + +from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup +from pybotx.models.message.outgoing_message import OutgoingMessage + +from . import strings +from .base import ( + PYBOTX_WIDGET_FLAG, + Widget, + build_outgoing_message_from_incoming, + outgoing_markup, +) +from .markup import merge_message_markup + +START_FROM_KEY = "pagination_start_from" +MESSAGE_IDS_KEY = "pagination_message_ids" + + +def _pagination_label(left_num: int, right_num: int, *, backward: bool) -> str: + arrow = strings.LEFT_ARROW if backward else strings.RIGHT_ARROW + direction = "Назад к" if backward else "Вперёд к" + if left_num == right_num: + range_label = str(left_num) + else: + range_label = f"{left_num}-{right_num}" + return f"{arrow} {direction} [{range_label}]" + + +def _safe_int(value: object, default: int) -> int: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return default + + +def _to_uuid_list(value: object) -> list[UUID]: + if not isinstance(value, list): + return [] + + uuids: list[UUID] = [] + for message_id in value: + try: + uuids.append(UUID(str(message_id))) + except ValueError: + continue + return uuids + + +class PaginationWidget(Widget): + def __init__( + self, + widget_content: list[OutgoingMessage | str], + paginate_by: int, + delay_between_messages: float = 0.5, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + + if paginate_by <= 0: + raise ValueError("'paginate_by' should be greater than 0") + + if delay_between_messages < 0: + raise ValueError("'delay_between_messages' should be non-negative") + + self.widget_content = [ + self._normalize_widget_message(content_item) for content_item in widget_content + ] + self.paginate_by = paginate_by + self.delay_between_messages = delay_between_messages + + self.content_len = len(self.widget_content) + self.start_from = _safe_int(self.message.data.get(START_FROM_KEY), default=0) + if self.start_from < 0: + self.start_from = 0 + + self.message_ids = _to_uuid_list(self.message.metadata.get(MESSAGE_IDS_KEY)) + self.empty_msg = build_outgoing_message_from_incoming( + message=self.message, + body=strings.EMPTY_MSG_SYMBOL, + bubbles=BubbleMarkup(), + keyboard=KeyboardMarkup(), + ) + + @property + def display_content(self) -> list[OutgoingMessage]: + return self.widget_content[self.start_from : self.start_from + self.paginate_by] + + def add_markup(self) -> None: + if self.content_len > self.paginate_by: + self.add_backward_btn() + self.add_forward_btn() + self.add_additional_markup() + + async def send_widget_message(self) -> None: + if self.message_ids: + await self._update_widget_messages() + else: + await self._send_new_widget_messages() + + def add_backward_btn(self) -> None: + if self.start_from < self.paginate_by: + return + + left_border = self.start_from - self.paginate_by + label = _pagination_label( + left_num=left_border + 1, + right_num=self.start_from, + backward=True, + ) + self.widget_bubbles.add_button( + label=label, + command=self.command, + data={START_FROM_KEY: left_border}, + ) + + def add_forward_btn(self) -> None: + left_border = self.start_from + self.paginate_by + if left_border >= self.content_len: + return + + right_border = min(left_border + self.paginate_by, self.content_len) + label = _pagination_label( + left_num=left_border + 1, + right_num=right_border, + backward=False, + ) + self.widget_bubbles.add_button( + label=label, + command=self.command, + data={START_FROM_KEY: left_border}, + new_row=False, + ) + + async def _send_new_widget_messages(self) -> None: + display_content = self.display_content + if not display_content: + return + + for widget_message in display_content[:-1]: + message_id = await self.bot.send(message=widget_message) + self.message_ids.append(message_id) + await asyncio.sleep(self.delay_between_messages) + + last_widget_message = display_content[-1] + self._prepare_last_message(last_widget_message) + await self.send_or_update_message(last_widget_message) + + async def _update_widget_messages(self) -> None: + display_content = self.display_content + + for index in range(self.paginate_by - 1): + if index >= len(self.message_ids): + break + + message_to_send = self.empty_msg + if index < len(display_content): + message_to_send = display_content[index] + + await self._edit_widget_message(self.message_ids[index], message_to_send) + await asyncio.sleep(self.delay_between_messages) + + last_item_idx = self.paginate_by - 1 + last_widget_message = self.empty_msg + if last_item_idx < len(display_content): + last_widget_message = display_content[last_item_idx] + + self._prepare_last_message(last_widget_message) + + if self.message.source_sync_id is None: + await self.bot.send(message=last_widget_message) + return + + await self._edit_widget_message(self.message.source_sync_id, last_widget_message) + + async def _edit_widget_message( + self, + sync_id: UUID, + widget_message: OutgoingMessage, + ) -> None: + await self.bot.edit_message( + bot_id=widget_message.bot_id, + sync_id=sync_id, + body=widget_message.body, + metadata=widget_message.metadata, + bubbles=widget_message.bubbles, + keyboard=widget_message.keyboard, + file=widget_message.file, + markup_auto_adjust=widget_message.markup_auto_adjust, + ) + + def _prepare_last_message(self, message: OutgoingMessage) -> None: + merged_markup = merge_message_markup( + outgoing_markup(message), + outgoing_markup(self.widget_message), + ) + message.bubbles = merged_markup.bubbles + message.keyboard = merged_markup.keyboard + + metadata = {} + if message.metadata: + metadata = dict(message.metadata) + metadata[MESSAGE_IDS_KEY] = [str(message_id) for message_id in self.message_ids] + metadata[PYBOTX_WIDGET_FLAG] = 1 + message.metadata = metadata + + def _normalize_widget_message( + self, + content_item: OutgoingMessage | str, + ) -> OutgoingMessage: + if isinstance(content_item, OutgoingMessage): + item = deepcopy(content_item) + item.bot_id = self.message.bot.id + item.chat_id = self.message.chat.id + return item + + return build_outgoing_message_from_incoming( + message=self.message, + body=str(content_item), + ) diff --git a/pybotx/widgets/presets.py b/pybotx/widgets/presets.py new file mode 100644 index 00000000..7cb6ab4c --- /dev/null +++ b/pybotx/widgets/presets.py @@ -0,0 +1,99 @@ +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any, TypeVar + +from pybotx.widgets.base import Widget +from pybotx.widgets.observability import PrometheusWidgetRunnerMetricsCollector +from pybotx.widgets.runner import ( + LoggingWidgetRunnerObserver, + RunnerAfterHook, + RunnerBeforeHook, + WidgetRunnerConfig, + WidgetRunnerObserver, +) + +W = TypeVar("W", bound=Widget) + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerObservabilityPreset: + observers: tuple[WidgetRunnerObserver, ...] + + def as_config(self) -> WidgetRunnerConfig: + return WidgetRunnerConfig(observers=self.observers) + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerPreset: + config: WidgetRunnerConfig + + def as_config(self) -> WidgetRunnerConfig: + return self.config + + def as_runner_kwargs(self) -> dict[str, object]: + return {"config": self.config} + + def as_widget_command_kwargs(self) -> dict[str, object]: + return {"config": self.config} + + +def build_widget_runner_observability_preset( + *, + enable_logging_observer: bool = True, + enable_prometheus_metrics: bool = False, + log_start: bool = False, + registry: Any | None = None, + metric_prefix: str = "pybotx_widget", + latency_buckets: tuple[float, ...] | None = None, + observers: Iterable[WidgetRunnerObserver] = (), +) -> WidgetRunnerObservabilityPreset: + configured_observers = tuple(observers) + if enable_logging_observer: + configured_observers = ( + LoggingWidgetRunnerObserver(log_start=log_start), + *configured_observers, + ) + if enable_prometheus_metrics: + configured_observers = ( + *configured_observers, + PrometheusWidgetRunnerMetricsCollector( + registry=registry, + metric_prefix=metric_prefix, + latency_buckets=latency_buckets, + ), + ) + + if not configured_observers: + raise ValueError( + "At least one widget runner observer should be enabled", + ) + + return WidgetRunnerObservabilityPreset(observers=configured_observers) + + +def build_production_widget_runner_preset( + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + before_hooks: Iterable[RunnerBeforeHook[W]] = (), + after_hooks: Iterable[RunnerAfterHook[W]] = (), + observability_preset: WidgetRunnerObservabilityPreset | None = None, +) -> WidgetRunnerPreset: + config = WidgetRunnerConfig.from_hooks(before=before, after=after) + + for before_hook in before_hooks: + config = config.extend( + before=before_hook, + ) + + for after_hook in after_hooks: + config = config.extend( + after=after_hook, + ) + + if observability_preset is None: + observability_preset = build_widget_runner_observability_preset() + + config = config.extend(observers=observability_preset.observers) + + return WidgetRunnerPreset(config=config) diff --git a/pybotx/widgets/range.py b/pybotx/widgets/range.py new file mode 100644 index 00000000..40377b8b --- /dev/null +++ b/pybotx/widgets/range.py @@ -0,0 +1,98 @@ +from collections.abc import Sequence +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +CURRENT_INDEX_KEY = "range_current_index" +ELEMENTS_KEY = "range_elements" + + +def _safe_index(value: Any, default: int = 0) -> int: + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return default + + +class RangeWidget(Widget): + def __init__( + self, + elements: Sequence[str] | None = None, + label_template: str = "Current element: {element}", + forward_label: str = "Вперед", + backward_label: str = "Назад", + empty_body: str = "Список пуст", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.label_template = label_template + self.forward_label = forward_label + self.backward_label = backward_label + self.empty_body = empty_body + + if elements is None: + incoming_elements = self.message.metadata.get(ELEMENTS_KEY, []) + if not isinstance(incoming_elements, list): + incoming_elements = [] + self.elements = [str(element) for element in incoming_elements] + else: + self.elements = [str(element) for element in elements] + + if not self.elements: + self.current_index = 0 + self.widget_message.body = self.empty_body + return + + current_index = _safe_index(self.message.data.get(CURRENT_INDEX_KEY)) + if current_index < 0: + current_index = 0 + if current_index >= len(self.elements): + current_index = len(self.elements) - 1 + + self.current_index = current_index + self.widget_message.body = self.label_template.format( + element=self.elements[self.current_index], + index=self.current_index + 1, + total=len(self.elements), + ) + self.widget_metadata[ELEMENTS_KEY] = self.elements + + def add_markup(self) -> None: + if not self.elements: + self.add_additional_markup() + return + + if self.current_index > 0: + self.widget_bubbles.add_button( + command=self.command, + label=self.backward_label, + data={CURRENT_INDEX_KEY: self.current_index - 1}, + ) + + if self.current_index < len(self.elements) - 1: + self.widget_bubbles.add_button( + command=self.command, + label=self.forward_label, + data={CURRENT_INDEX_KEY: self.current_index + 1}, + new_row=self.current_index == 0, + ) + + self.add_additional_markup() + + @classmethod + def get_value(cls, message: IncomingMessage) -> str: + elements = message.metadata.get(ELEMENTS_KEY, []) + if not isinstance(elements, list) or not elements: + raise RuntimeError("Range elements are not found in metadata.") + + current_index = _safe_index(message.data.get(CURRENT_INDEX_KEY)) + if current_index < 0 or current_index >= len(elements): + raise RuntimeError("Current index is out of range.") + + return str(elements[current_index]) diff --git a/pybotx/widgets/runner.py b/pybotx/widgets/runner.py new file mode 100644 index 00000000..90e9b506 --- /dev/null +++ b/pybotx/widgets/runner.py @@ -0,0 +1,473 @@ +import inspect +from collections.abc import Iterable +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from functools import wraps +from time import perf_counter +from typing import TYPE_CHECKING, TypeVar, cast, overload + +from pybotx.bot.bot import Bot +from pybotx.logger import logger +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +if TYPE_CHECKING: + from .base import WidgetResultMode + +W = TypeVar("W", bound=Widget) +T = TypeVar("T") + + +@dataclass(slots=True) +class RunnerHookResult: + result: str | None = None + should_display: bool = True + widget: Widget | None = None + + +BeforeHookResult = RunnerHookResult | None | Awaitable[RunnerHookResult | None] +AfterHookResult = str | None | Awaitable[str | None] + +RunnerBeforeHook = Callable[[W], BeforeHookResult] +RunnerAfterHook = Callable[[W], AfterHookResult] + +RunnerHookResolverResult = ( + RunnerHookResult + | str + | None + | Awaitable[RunnerHookResult | str | None] +) +RunnerHookResolver = Callable[[W], RunnerHookResolverResult] + +ActionGetterResult = str | Awaitable[str] +ActionGetter = Callable[[W], ActionGetterResult] + +ActionHookResult = ( + RunnerHookResult + | str + | None + | Awaitable[RunnerHookResult | str | None] +) +ActionHook = Callable[[W], ActionHookResult] +ObserverHookResult = None | Awaitable[None] + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerStartedEvent: + message: IncomingMessage + bot: Bot + widget: Widget + + @property + def widget_name(self) -> str: + return type(self.widget).__name__ + + @property + def command(self) -> str: + return self.widget.command + + @property + def result_mode(self) -> "WidgetResultMode": + return self.widget.result_mode + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerFinishedEvent: + message: IncomingMessage + bot: Bot + initial_widget: Widget + widget: Widget + duration_seconds: float + displayed: bool + skipped_by_before: bool + before_results_sent: int + after_results_sent: int + error: Exception | None = None + + @property + def initial_widget_name(self) -> str: + return type(self.initial_widget).__name__ + + @property + def widget_name(self) -> str: + return type(self.widget).__name__ + + @property + def command(self) -> str: + return self.widget.command + + @property + def result_mode(self) -> "WidgetResultMode": + return self.widget.result_mode + + +class WidgetRunnerObserver: + def on_started(self, event: WidgetRunnerStartedEvent) -> ObserverHookResult: + return None + + def on_finished(self, event: WidgetRunnerFinishedEvent) -> ObserverHookResult: + return None + + +@dataclass(frozen=True, slots=True) +class WidgetRunnerConfig: + before_hooks: tuple[RunnerBeforeHook[Widget], ...] = () + after_hooks: tuple[RunnerAfterHook[Widget], ...] = () + observers: tuple[WidgetRunnerObserver, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "before_hooks", tuple(self.before_hooks)) + object.__setattr__(self, "after_hooks", tuple(self.after_hooks)) + object.__setattr__(self, "observers", tuple(self.observers)) + + @classmethod + def from_hooks( + cls, + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + observers: Iterable[WidgetRunnerObserver] = (), + ) -> "WidgetRunnerConfig": + before_hooks: tuple[RunnerBeforeHook[Widget], ...] + after_hooks: tuple[RunnerAfterHook[Widget], ...] + before_hooks = () if before is None else (cast(RunnerBeforeHook[Widget], before),) + after_hooks = () if after is None else (cast(RunnerAfterHook[Widget], after),) + return cls( + before_hooks=before_hooks, + after_hooks=after_hooks, + observers=tuple(observers), + ) + + def extend( + self, + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + observers: Iterable[WidgetRunnerObserver] = (), + ) -> "WidgetRunnerConfig": + before_hooks = self.before_hooks + after_hooks = self.after_hooks + if before is not None: + before_hooks += (cast(RunnerBeforeHook[Widget], before),) + if after is not None: + after_hooks += (cast(RunnerAfterHook[Widget], after),) + return WidgetRunnerConfig( + before_hooks=before_hooks, + after_hooks=after_hooks, + observers=self.observers + tuple(observers), + ) + + +@dataclass(frozen=True, slots=True) +class LoggingWidgetRunnerObserver(WidgetRunnerObserver): + log_start: bool = False + + async def on_started(self, event: WidgetRunnerStartedEvent) -> None: + if not self.log_start: + return + + logger.bind( + widget_name=event.widget_name, + widget_command=event.command, + widget_result_mode=event.result_mode, + ).debug("Widget run started") + + async def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + bound_logger = logger.bind( + widget_name=event.widget_name, + initial_widget_name=event.initial_widget_name, + widget_command=event.command, + widget_result_mode=event.result_mode, + widget_displayed=event.displayed, + widget_skipped_by_before=event.skipped_by_before, + widget_before_results_sent=event.before_results_sent, + widget_after_results_sent=event.after_results_sent, + widget_duration_seconds=event.duration_seconds, + ) + if event.error is None: + bound_logger.info("Widget run finished") + return + + bound_logger.opt(exception=event.error).warning("Widget run failed") + + +class WidgetRunner: + def __init__( + self, + message: IncomingMessage, + bot: Bot, + *, + config: WidgetRunnerConfig | None = None, + ) -> None: + self.message = message + self.bot = bot + self.config = config or WidgetRunnerConfig() + + async def run( + self, + *, + widget: W, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + ) -> None: + initial_widget = widget + before_hooks = self._build_before_hooks(before) + after_hooks = self._build_after_hooks(after) + started_event = WidgetRunnerStartedEvent( + message=self.message, + bot=self.bot, + widget=initial_widget, + ) + await self._notify_observers_started(started_event) + + displayed = False + skipped_by_before = False + before_results_sent = 0 + after_results_sent = 0 + started_at = perf_counter() + try: + for before_hook in before_hooks: + before_result = await _resolve_awaitable(before_hook(widget)) + if before_result is None: + continue + + if before_result.widget is not None: + widget = cast(W, before_result.widget) + + if before_result.result is not None: + await widget.send_result(before_result.result) + before_results_sent += 1 + + if not before_result.should_display: + skipped_by_before = True + break + + if not skipped_by_before: + await widget.display() + displayed = True + + for after_hook in after_hooks: + post_result = await _resolve_awaitable(after_hook(widget)) + if post_result is None: + continue + + await widget.send_result(post_result) + after_results_sent += 1 + except Exception as error: + await self._notify_observers_finished( + WidgetRunnerFinishedEvent( + message=self.message, + bot=self.bot, + initial_widget=initial_widget, + widget=widget, + duration_seconds=perf_counter() - started_at, + displayed=displayed, + skipped_by_before=skipped_by_before, + before_results_sent=before_results_sent, + after_results_sent=after_results_sent, + error=error, + ), + ) + raise + + await self._notify_observers_finished( + WidgetRunnerFinishedEvent( + message=self.message, + bot=self.bot, + initial_widget=initial_widget, + widget=widget, + duration_seconds=perf_counter() - started_at, + displayed=displayed, + skipped_by_before=skipped_by_before, + before_results_sent=before_results_sent, + after_results_sent=after_results_sent, + ), + ) + + def _build_before_hooks( + self, + before: RunnerBeforeHook[W] | None, + ) -> tuple[RunnerBeforeHook[W], ...]: + hooks = tuple(cast(RunnerBeforeHook[W], hook) for hook in self.config.before_hooks) + if before is not None: + hooks += (before,) + return hooks + + def _build_after_hooks( + self, + after: RunnerAfterHook[W] | None, + ) -> tuple[RunnerAfterHook[W], ...]: + hooks: tuple[RunnerAfterHook[W], ...] = () + if after is not None: + hooks += (after,) + hooks += tuple(cast(RunnerAfterHook[W], hook) for hook in self.config.after_hooks) + return hooks + + async def _notify_observers_started(self, event: WidgetRunnerStartedEvent) -> None: + for observer in self.config.observers: + try: + await _resolve_awaitable(observer.on_started(event)) + except Exception: + logger.opt(exception=True).warning( + "Widget runner observer failed during start: {observer_name}", + observer_name=type(observer).__name__, + ) + + async def _notify_observers_finished(self, event: WidgetRunnerFinishedEvent) -> None: + for observer in self.config.observers: + try: + await _resolve_awaitable(observer.on_finished(event)) + except Exception: + logger.opt(exception=True).warning( + "Widget runner observer failed during finish: {observer_name}", + observer_name=type(observer).__name__, + ) + + +@overload +def widget_command( + handler: Callable[[IncomingMessage, Bot], W | None | Awaitable[W | None]], +) -> Callable[[IncomingMessage, Bot], Awaitable[None]]: # pragma: no cover + ... + + +@overload +def widget_command( + *, + config: WidgetRunnerConfig, +) -> Callable[ + [Callable[[IncomingMessage, Bot], Widget | None | Awaitable[Widget | None]]], + Callable[[IncomingMessage, Bot], Awaitable[None]], +]: # pragma: no cover + ... + + +@overload +def widget_command( + handler: None = None, + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + config: WidgetRunnerConfig | None = None, +) -> Callable[ + [Callable[[IncomingMessage, Bot], W | None | Awaitable[W | None]]], + Callable[[IncomingMessage, Bot], Awaitable[None]], +]: # pragma: no cover + ... + + +def widget_command( + handler: Callable[[IncomingMessage, Bot], W | None | Awaitable[W | None]] | None = None, + *, + before: RunnerBeforeHook[W] | None = None, + after: RunnerAfterHook[W] | None = None, + config: WidgetRunnerConfig | None = None, +) -> ( + Callable[[IncomingMessage, Bot], Awaitable[None]] + | Callable[ + [Callable[[IncomingMessage, Bot], W | None | Awaitable[W | None]]], + Callable[[IncomingMessage, Bot], Awaitable[None]], + ] +): + def _decorator( + wrapped_handler: Callable[[IncomingMessage, Bot], W | None | Awaitable[W | None]], + ) -> Callable[[IncomingMessage, Bot], Awaitable[None]]: + @wraps(wrapped_handler) + async def _wrapped(message: IncomingMessage, bot: Bot) -> None: + widget = await _resolve_awaitable(wrapped_handler(message, bot)) + if widget is None: + return + + runner = WidgetRunner(message, bot, config=config) + await runner.run(widget=widget, before=before, after=after) + + return _wrapped + + if handler is not None: + return _decorator(handler) + + return _decorator + + +def on_data_key( + key: str, + resolver: RunnerHookResolver[W], + *, + should_display: bool = False, +) -> RunnerBeforeHook[W]: + async def _hook(widget: W) -> RunnerHookResult | None: + if key not in widget.message.data: + return None + + resolved_result = await _resolve_awaitable(resolver(widget)) + return _normalize_before_hook_result( + resolved_result, + should_display=should_display, + ) + + return _hook + + +def on_completed( + is_completed: Callable[[W], bool | Awaitable[bool]], + resolver: Callable[[W], str | None | Awaitable[str | None]], +) -> RunnerAfterHook[W]: + async def _hook(widget: W) -> str | None: + completed = await _resolve_awaitable(is_completed(widget)) + if not completed: + return None + + return await _resolve_awaitable(resolver(widget)) + + return _hook + + +def on_action( + get_action: ActionGetter[W], + actions: dict[str, str | RunnerHookResult | ActionHook[W]], + *, + default: str | RunnerHookResult | ActionHook[W] | None = None, + should_display: bool = False, +) -> RunnerBeforeHook[W]: + async def _hook(widget: W) -> RunnerHookResult | None: + try: + action = await _resolve_awaitable(get_action(widget)) + except RuntimeError: + return None + + resolver = actions.get(action, default) + if resolver is None: + return None + + if callable(resolver): + resolved_result = await _resolve_awaitable(resolver(widget)) + else: + resolved_result = resolver + + return _normalize_before_hook_result( + resolved_result, + should_display=should_display, + ) + + return _hook + + +def _normalize_before_hook_result( + result: RunnerHookResult | str | None, + *, + should_display: bool, +) -> RunnerHookResult | None: + if result is None or isinstance(result, RunnerHookResult): + return result + + return RunnerHookResult( + result=str(result), + should_display=should_display, + ) + + +async def _resolve_awaitable(value: T | Awaitable[T]) -> T: + if inspect.isawaitable(value): + return await cast(Awaitable[T], value) + return value diff --git a/pybotx/widgets/search_select.py b/pybotx/widgets/search_select.py new file mode 100644 index 00000000..eee5f4d2 --- /dev/null +++ b/pybotx/widgets/search_select.py @@ -0,0 +1,121 @@ +from pybotx.bot.bot import Bot +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import WidgetResultMode +from .markup import MessageMarkup +from .select import SELECT_VALUE_KEY, SelectOption, SelectWidget, _normalize_option + +SEARCH_QUERY_KEY = "search_query" +SEARCH_PAGE_KEY = "search_page" + + +def _safe_page(value: object) -> int: + if isinstance(value, int) and value >= 0: + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return 0 + + +class SearchSelectWidget(SelectWidget): + def __init__( + self, + options: list[str | SelectOption], + label: str, + page_size: int = 5, + empty_label: str = "Ничего не найдено", + selected_prefix: str = "• ", + *, + message: IncomingMessage, + bot: Bot, + command: str, + additional_markup: MessageMarkup | None = None, + result_mode: WidgetResultMode | None = None, + ) -> None: + super().__init__( + options=options, + label=label, + selected_prefix=selected_prefix, + message=message, + bot=bot, + command=command, + additional_markup=additional_markup, + result_mode=result_mode, + ) + self.page_size = max(page_size, 1) + self.empty_label = empty_label + + self.query = str( + self.message.data.get(SEARCH_QUERY_KEY, self.message.argument), + ).strip() + self.page = _safe_page(self.message.data.get(SEARCH_PAGE_KEY)) + + self.all_options = [_normalize_option(option) for option in options] + self.filtered_options = self._filter_options() + self.page = min(self.page, self._max_page()) + self.displayed_options = self._slice_page() + self.widget_message.body = self._compose_body(label) + + def add_markup(self) -> None: + if not self.displayed_options: + self.widget_bubbles.add_button( + command=self.command, + label=self.empty_label, + data={SEARCH_QUERY_KEY: "", SEARCH_PAGE_KEY: 0}, + ) + self.add_additional_markup() + return + + for option in self.displayed_options: + self.widget_bubbles.add_button( + command=self.command, + label=option.label, + data={SELECT_VALUE_KEY: option.value}, + ) + + if self.page > 0: + self.widget_bubbles.add_button( + command=self.command, + label="Назад", + data={SEARCH_QUERY_KEY: self.query, SEARCH_PAGE_KEY: self.page - 1}, + ) + + if self.page < self._max_page(): + self.widget_bubbles.add_button( + command=self.command, + label="Вперед", + data={SEARCH_QUERY_KEY: self.query, SEARCH_PAGE_KEY: self.page + 1}, + new_row=self.page == 0, + ) + + self.add_additional_markup() + + def _filter_options(self) -> list[SelectOption]: + if not self.query: + return self.all_options + + query_lower = self.query.lower() + return [ + option + for option in self.all_options + if query_lower in option.label.lower() or query_lower in option.value.lower() + ] + + def _max_page(self) -> int: + if not self.filtered_options: + return 0 + + return (len(self.filtered_options) - 1) // self.page_size + + def _slice_page(self) -> list[SelectOption]: + start = self.page * self.page_size + end = start + self.page_size + return self.filtered_options[start:end] + + def _compose_body(self, label: str) -> str: + if not self.query: + return label + + return f"{label}\nПоиск: {self.query}" diff --git a/pybotx/widgets/select.py b/pybotx/widgets/select.py new file mode 100644 index 00000000..c5fe2b4a --- /dev/null +++ b/pybotx/widgets/select.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Any + +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import Widget + +SELECT_VALUE_KEY = "select_value" + + +@dataclass(frozen=True, slots=True) +class SelectOption: + value: str + label: str + + +def _normalize_option(raw_option: str | SelectOption) -> SelectOption: + if isinstance(raw_option, SelectOption): + return raw_option + + value = str(raw_option) + return SelectOption(value=value, label=value) + + +class SelectWidget(Widget): + def __init__( + self, + options: list[str | SelectOption], + label: str, + selected_prefix: str = "• ", + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.options = [_normalize_option(option) for option in options] + self.selected_prefix = selected_prefix + self.selected_value = self.message.data.get(SELECT_VALUE_KEY) + self.widget_message.body = label + + def add_markup(self) -> None: + for option in self.options: + label = option.label + if self.selected_value == option.value: + label = f"{self.selected_prefix}{label}" + + self.widget_bubbles.add_button( + command=self.command, + label=label, + data={SELECT_VALUE_KEY: option.value}, + ) + + self.add_additional_markup() + + @classmethod + def get_value(cls, message: IncomingMessage) -> str: + selected_value = message.data.get(SELECT_VALUE_KEY) + if selected_value is None: + raise RuntimeError("Selected value is not found.") + + return str(selected_value) diff --git a/pybotx/widgets/service.py b/pybotx/widgets/service.py new file mode 100644 index 00000000..650d0b73 --- /dev/null +++ b/pybotx/widgets/service.py @@ -0,0 +1,37 @@ +from pybotx.bot.bot import Bot, MissingOptionalAttachment +from pybotx.missing import Undefined +from pybotx.models.message.incoming_message import IncomingMessage + +from .base import PYBOTX_WIDGET_FLAG, build_outgoing_message_from_incoming +from .markup import MessageMarkup + + +async def send_or_update_message( + message: IncomingMessage, + bot: Bot, + body: str, + markup: MessageMarkup | None = None, + msg_file: MissingOptionalAttachment = Undefined, +) -> None: + markup = markup or MessageMarkup() + + if message.metadata.get(PYBOTX_WIDGET_FLAG) and message.source_sync_id is not None: + await bot.edit_message( + bot_id=message.bot.id, + sync_id=message.source_sync_id, + body=body, + metadata=dict(message.metadata), + bubbles=markup.bubbles, + keyboard=markup.keyboard, + file=msg_file, + ) + return + + out_message = build_outgoing_message_from_incoming( + message=message, + body=body, + bubbles=markup.bubbles, + keyboard=markup.keyboard, + file=msg_file if msg_file is not None else Undefined, + ) + await bot.send(message=out_message) diff --git a/pybotx/widgets/strings.py b/pybotx/widgets/strings.py new file mode 100644 index 00000000..1c2e94cb --- /dev/null +++ b/pybotx/widgets/strings.py @@ -0,0 +1,41 @@ +from typing import Final + + +MONTHS: Final[dict[int, str]] = { + 1: "Янв", + 2: "Фев", + 3: "Мар", + 4: "Апр", + 5: "Май", + 6: "Июн", + 7: "Июл", + 8: "Авг", + 9: "Сен", + 10: "Окт", + 11: "Ноя", + 12: "Дек", +} + +WEEKDAYS: Final[tuple[str, ...]] = ("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс") + +SELECTED_VALUE_LABEL: Final[str] = "{label} {selected_val}" +CHOOSE_LABEL: Final[str] = "Выбрать" +FILL_LABEL: Final[str] = "Ввести" +EMPTY: Final[str] = "[Пусто]" + +LEFT_ARROW: Final[str] = "⬅️" +RIGHT_ARROW: Final[str] = "➡️" +UP_ARROW: Final[str] = "⬆️" +DOWN_ARROW: Final[str] = "⬇️" + +CHECKBOX_CHECKED: Final[str] = "☑" +CHECKBOX_UNCHECKED: Final[str] = "☐" +CHECK_MARK: Final[str] = "✔️" +ENVELOPE: Final[str] = "✉️" +PENCIL: Final[str] = "✏️" +CROSS_MARK: Final[str] = "❌" + +CAL_DATE_SELECTED: Final[str] = "Дата выбрана" +SELECT_DATE: Final[str] = "Выберите дату" + +EMPTY_MSG_SYMBOL: Final[str] = "-" diff --git a/pybotx/widgets/table.py b/pybotx/widgets/table.py new file mode 100644 index 00000000..4248a7b5 --- /dev/null +++ b/pybotx/widgets/table.py @@ -0,0 +1,176 @@ +from typing import Any + +from .base import Widget + +TABLE_PAGE_KEY = "table_page" +TABLE_SORT_KEY = "table_sort" +TABLE_DESC_KEY = "table_desc" +TABLE_QUERY_KEY = "table_query" + + +def _safe_int(value: object) -> int: + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + return 0 + + +def _safe_bool(value: object) -> bool: + if isinstance(value, bool): + return value + + if isinstance(value, str): + return value.lower() in {"true", "1", "yes"} + + return False + + +class TableWidget(Widget): + def __init__( + self, + rows: list[dict[str, Any]], + columns: list[str], + label: str = "Таблица", + page_size: int = 5, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.rows = rows + self.columns = columns + self.label = label + self.page_size = max(page_size, 1) + + self.query = str(self.message.data.get(TABLE_QUERY_KEY, self.message.argument)).strip() + self.sort_by = str( + self.message.data.get(TABLE_SORT_KEY, self.message.metadata.get(TABLE_SORT_KEY, "")), + ) + if self.sort_by not in self.columns and self.columns: + self.sort_by = self.columns[0] + + self.sort_desc = _safe_bool( + self.message.data.get(TABLE_DESC_KEY, self.message.metadata.get(TABLE_DESC_KEY, False)), + ) + self.page = _safe_int( + self.message.data.get(TABLE_PAGE_KEY, self.message.metadata.get(TABLE_PAGE_KEY, 0)), + ) + + self.filtered_rows = self._filter_rows() + self.sorted_rows = self._sort_rows() + self.page = min(max(self.page, 0), self._max_page()) + self.page_rows = self._slice_rows() + + self.widget_message.body = self._build_body() + self._sync_metadata() + + def add_markup(self) -> None: + if self.page > 0: + self.widget_bubbles.add_button( + command=self.command, + label="Назад", + data=self._state_data(page=self.page - 1), + ) + + if self.page < self._max_page(): + self.widget_bubbles.add_button( + command=self.command, + label="Вперед", + data=self._state_data(page=self.page + 1), + new_row=self.page == 0, + ) + + for index, column in enumerate(self.columns): + next_desc = self.sort_desc + if column == self.sort_by: + next_desc = not self.sort_desc + + self.widget_bubbles.add_button( + command=self.command, + label=f"Сорт: {column}", + data=self._state_data( + page=0, + sort_by=column, + desc=next_desc, + ), + new_row=index == 0, + ) + + if self.query: + self.widget_bubbles.add_button( + command=self.command, + label="Сбросить фильтр", + data=self._state_data(query="", page=0), + ) + + self.add_additional_markup() + + def _filter_rows(self) -> list[dict[str, Any]]: + if not self.query: + return self.rows + + query_lower = self.query.lower() + return [ + row + for row in self.rows + if any(query_lower in str(row.get(column, "")).lower() for column in self.columns) + ] + + def _sort_rows(self) -> list[dict[str, Any]]: + if not self.sort_by: + return self.filtered_rows + + return sorted( + self.filtered_rows, + key=lambda row: str(row.get(self.sort_by, "")).lower(), + reverse=self.sort_desc, + ) + + def _max_page(self) -> int: + if not self.sorted_rows: + return 0 + + return (len(self.sorted_rows) - 1) // self.page_size + + def _slice_rows(self) -> list[dict[str, Any]]: + left = self.page * self.page_size + right = left + self.page_size + return self.sorted_rows[left:right] + + def _build_body(self) -> str: + lines = [self.label] + if self.query: + lines.append(f"Фильтр: {self.query}") + + if not self.page_rows: + lines.append("Нет данных") + return "\n".join(lines) + + lines.append(" | ".join(self.columns)) + for row in self.page_rows: + lines.append(" | ".join(str(row.get(column, "")) for column in self.columns)) + + return "\n".join(lines) + + def _state_data( + self, + *, + page: int | None = None, + sort_by: str | None = None, + desc: bool | None = None, + query: str | None = None, + ) -> dict[str, Any]: + return { + TABLE_PAGE_KEY: self.page if page is None else page, + TABLE_SORT_KEY: self.sort_by if sort_by is None else sort_by, + TABLE_DESC_KEY: self.sort_desc if desc is None else desc, + TABLE_QUERY_KEY: self.query if query is None else query, + } + + def _sync_metadata(self) -> None: + self.widget_metadata[TABLE_PAGE_KEY] = self.page + self.widget_metadata[TABLE_SORT_KEY] = self.sort_by + self.widget_metadata[TABLE_DESC_KEY] = self.sort_desc + self.widget_metadata[TABLE_QUERY_KEY] = self.query diff --git a/pybotx/widgets/undefined.py b/pybotx/widgets/undefined.py new file mode 100644 index 00000000..dcb42f63 --- /dev/null +++ b/pybotx/widgets/undefined.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class Undefined: + _instance: Optional["Undefined"] = None + + def __new__(cls) -> "Undefined": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + +undefined = Undefined() diff --git a/pyproject.toml b/pyproject.toml index 4fcae241..124a99ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,55 +1,63 @@ -[tool.poetry] +[project] name = "pybotx" -version = "0.76.3" +version = "0.77.0" description = "A python library for interacting with eXpress BotX API" authors = [ - "Sidnev Nikolay ", - "Maxim Gorbachev ", - "Alexander Samoylenko ", - "Arseniy Zhiltsov ", + { name = "Sidnev Nikolay", email = "nsidnev@ccsteam.ru" }, + { name = "Maxim Gorbachev", email = "mgorbachev@ccsteam.ru" }, + { name = "Alexander Samoylenko", email = "alexandr.samojlenko@ccsteam.ru" }, + { name = "Arseniy Zhiltsov", email = "arseniy.zhiltsov@ccsteam.ru" }, ] readme = "README.md" -repository = "https://github.com/ExpressApp/pybotx" - - -[tool.poetry.dependencies] -python = ">=3.10,<4.0" - -aiofiles = "^25.1.0" -httpx = "^0.28.1" -# The v1.0.3 cause some troubles with no-wait callbacks functionality. -# It will be fixed in the next versions. -# https://github.com/encode/httpcore/pull/880 -httpcore = "^1.0.9" -loguru = "^0.7.3" -pydantic = "^2.12.5" -aiocsv = "^1.4.0" -pyjwt = "^2.11.0" -mypy-extensions = "^1.1.0" +requires-python = ">=3.10,<4.0" +dependencies = [ + "aiofiles>=25.1.0,<26", + "httpx>=0.28.1,<0.29", + # The v1.0.3 cause some troubles with no-wait callbacks functionality. + # It will be fixed in the next versions. + # https://github.com/encode/httpcore/pull/880 + "httpcore>=1.0.9,<2", + "loguru>=0.7.3,<0.8", + "pydantic>=2.12.5,<3", + "aiocsv>=1.4.0,<2", + "pyjwt>=2.11.0,<3", + "mypy-extensions>=1.1.0,<2", + "tenacity>=9.1.2,<10", +] -[tool.poetry.group.dev.dependencies] -mypy = "^1.19.1" -bandit = "^1.9.3" # https://github.com/PyCQA/bandit/issues/837 +[project.urls] +Repository = "https://github.com/ExpressApp/pybotx" -pytest = "^9.0.2" -pytest-asyncio = "^1.3.0" -pytest-cov = "^7.0.0" -pytest-timeout = "^2.4.0" -pytest-xdist = "^3.8.0" -hypothesis = "^6.151.5" -requests = "^2.32.5" -respx = "^0.22.0" -factory-boy = "^3.3.3" -deepdiff = "^8.6.1" +[project.scripts] +pybotx = "pybotx.cli:main" -fastapi = "^0.128.1" -starlette = ">=0.40,<0.51" # TODO: Drop dependency after updating end-to-end test -uvicorn = "^0.40.0" -ruff = "^0.15.0" +[dependency-groups] +dev = [ + "mypy>=1.19.1,<2", + "bandit>=1.9.3,<2", # https://github.com/PyCQA/bandit/issues/837 + "pytest>=9.0.2,<10", + "pytest-asyncio>=1.3.0,<2", + "pytest-cov>=7.0.0,<8", + "pytest-timeout>=2.4.0,<3", + "pytest-xdist>=3.8.0,<4", + "hypothesis>=6.151.5,<7", + "requests>=2.32.5,<3", + "respx>=0.22.0,<0.23", + "factory-boy>=3.3.3,<4", + "deepdiff>=8.6.1,<9", + "fastapi>=0.128.1,<0.129", + "starlette>=0.40,<0.51", # TODO: Drop dependency after updating end-to-end test + "uvicorn>=0.40.0,<0.41", + "ruff>=0.15.0,<0.16", + "prometheus-client>=0.26.0", +] [build-system] -requires = ["poetry>=1.2.0"] -build-backend = "poetry.masonry.api" +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pybotx"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/scripts/docs-lint b/scripts/docs-lint index 8636692d..459c110d 100755 --- a/scripts/docs-lint +++ b/scripts/docs-lint @@ -1,20 +1,27 @@ #!/usr/bin/env bash -set -e +set -euo pipefail -snippets_dir_path=".snippets" +snippets_dir_path="$(mktemp -d)" +trap 'rm -rf "${snippets_dir_path}"' EXIT -rm -rf "${snippets_dir_path}" -mkdir "${snippets_dir_path}" +python - "${snippets_dir_path}" <<'PY' +from pathlib import Path +import re +import sys -snippet_number=1 -while IFS= read -d '' -r snippet || [[ -n "${snippet}" ]]; do - printf "%s" "${snippet}" \ - > "${snippets_dir_path}/snippet${snippet_number}.py" - snippet_number=$((snippet_number+1)) -done < <(grep -Pzo '(?s)(?<=```python\n).*?(?=```)' README.md); +readme = Path("README.md").read_text(encoding="utf-8") +snippets = re.findall(r"```python\n(.*?)```", readme, flags=re.DOTALL) +if not snippets: + raise SystemExit("README.md does not contain Python snippets") + +snippets_dir = Path(sys.argv[1]) +for snippet_number, snippet in enumerate(snippets, start=1): + (snippets_dir / f"snippet{snippet_number}.py").write_text( + snippet, + encoding="utf-8", + ) +PY mypy "${snippets_dir_path}" ruff check "${snippets_dir_path}" --ignore F403,F405 - -rm -rf "${snippets_dir_path}" diff --git a/tests/client/chats_api/test_chat_info.py b/tests/client/chats_api/test_chat_info.py index 83d8f0c9..856a900b 100644 --- a/tests/client/chats_api/test_chat_info.py +++ b/tests/client/chats_api/test_chat_info.py @@ -198,7 +198,7 @@ async def test__chat_info__succeed_voex_call( assert_deep_equal( chat_info, ChatInfo( - chat_type=ChatTypes.GROUP_CHAT, + chat_type=ChatTypes.VOEX_CALL, creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), description=None, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py index 16f00df1..3ae94f07 100644 --- a/tests/client/chats_api/test_create_chat.py +++ b/tests/client/chats_api/test_create_chat.py @@ -225,6 +225,11 @@ def test__create_chat_payload__convert_chat_type_validator() -> None: with pytest.raises(ValueError, match="Bot cannot create a chat of type 'voex_call'"): BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] + # VOEX_CALL is also rejected through the public domain enum. + values = {"chat_type": ChatTypes.VOEX_CALL} + with pytest.raises(ValueError, match="Bot cannot create a chat of type 'voex_call'"): + BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] + # Test with another APIChatTypes value (should remain unchanged) values = {"chat_type": APIChatTypes.CHAT} # type: ignore[dict-item] result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] diff --git a/tests/client/notifications_api/test_direct_notification.py b/tests/client/notifications_api/test_direct_notification.py index 88f7ae99..72677031 100644 --- a/tests/client/notifications_api/test_direct_notification.py +++ b/tests/client/notifications_api/test_direct_notification.py @@ -158,11 +158,15 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: # - Act - async with bot_factory(collectors=[collector]) as bot: - bot.async_execute_raw_bot_command(payload, verify_request=False) + await bot.dispatch_raw_command( + payload, + verify_request=False, + wait=False, + ) await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -279,11 +283,15 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: # - Act - async with bot_factory(collectors=[collector]) as bot: - bot.async_execute_raw_bot_command(payload, verify_request=False) + await bot.dispatch_raw_command( + payload, + verify_request=False, + wait=False, + ) await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -401,7 +409,7 @@ async def test__send_message__callback_error_raised( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "error", "sync_id": SYNC_ID, @@ -448,7 +456,7 @@ async def test__send_message__miminally_filled_succeed( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -607,7 +615,7 @@ async def test__send_message__maximum_filled_succeed( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -703,6 +711,7 @@ async def test__send_message__all_mentions_types_succeed( { "mention_type": "all", "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": None, }, ], }, @@ -729,7 +738,7 @@ async def test__send_message__all_mentions_types_succeed( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -816,7 +825,7 @@ async def test__send_message__message_body_max_length_succeed( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, diff --git a/tests/client/notifications_api/test_internal_bot_notification.py b/tests/client/notifications_api/test_internal_bot_notification.py index 534684b8..6fa7e3ad 100644 --- a/tests/client/notifications_api/test_internal_bot_notification.py +++ b/tests/client/notifications_api/test_internal_bot_notification.py @@ -134,7 +134,7 @@ async def test__send_internal_bot_notification__callback_error_raised( ) await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "error", "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", @@ -180,7 +180,7 @@ async def test__send_internal_bot_notification__succeed( ) await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", diff --git a/tests/client/notifications_api/test_markup.py b/tests/client/notifications_api/test_markup.py index 2361b3d3..6c34e3ea 100644 --- a/tests/client/notifications_api/test_markup.py +++ b/tests/client/notifications_api/test_markup.py @@ -93,7 +93,7 @@ async def test__markup__defaults_filled( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -212,7 +212,7 @@ async def test__markup__correctly_built( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -350,7 +350,7 @@ async def test__markup__color_and_align( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, @@ -447,7 +447,7 @@ async def test__markup__link( await asyncio.sleep(0) # Return control to event loop - await bot.set_raw_botx_method_result( + await bot.deliver_raw_callback( { "status": "ok", "sync_id": SYNC_ID, diff --git a/tests/client/test_botx_method.py b/tests/client/test_botx_method.py index b81f97a4..0253bfd5 100644 --- a/tests/client/test_botx_method.py +++ b/tests/client/test_botx_method.py @@ -1,19 +1,37 @@ from http import HTTPStatus -from typing import Literal +from types import SimpleNamespace +from typing import Any, Literal, cast from uuid import UUID import httpx import pytest from respx.router import MockRouter +from tenacity import ( + AsyncRetrying, + RetryCallState, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) from pybotx import ( + BotXOperation, + BotXRetryStrategy, + BotXRetryPolicy, BotAccountWithSecret, InvalidBotXResponsePayloadError, InvalidBotXStatusCodeError, + OperationNameAllowlistBotXRetryRequestPolicy, + RetryAllBotXRequestsPolicy, ) from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.client.botx_method import BotXMethod, response_exception_thrower from pybotx.client.exceptions.base import BaseClientError +from pybotx.client.observability import ( + BotXRequestMetadata, + BotXRequestResult, + BotXRetryEvent, +) from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel @@ -64,12 +82,135 @@ async def execute( ) +class SearchByEmailMethod(BotXMethod): + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + ) -> BotXAPIFooBarResponsePayload: + response = await self._botx_method_call( + "POST", + self._build_url("/api/v3/botx/users/by_email"), + json={"emails": [f"user{payload.baz}@example.com"]}, + ) + + return self._verify_and_extract_api_model( + BotXAPIFooBarResponsePayload, + response, + ) + + +class ObserverSpy: + def __init__(self) -> None: + self.start_events: list[BotXRequestMetadata] = [] + self.retry_events: list[tuple[BotXRequestMetadata, BotXRetryEvent]] = [] + self.finish_events: list[tuple[BotXRequestMetadata, BotXRequestResult]] = [] + + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + self.start_events.append(metadata) + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + self.retry_events.append((metadata, retry_event)) + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + self.finish_events.append((metadata, result)) + + +class BrokenObserver: + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + raise RuntimeError("start") + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + raise RuntimeError("retry") + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + raise RuntimeError("finish") + + +class TwoAttemptsRetryStrategy(BotXRetryStrategy): + def build_retrying( + self, + *, + retry_policy: BotXRetryPolicy, + retry_exceptions: tuple[type[BaseException], ...], + before_sleep: Any, + ) -> AsyncRetrying: + return AsyncRetrying( + stop=stop_after_attempt(2), + wait=wait_fixed(0.0), + retry=retry_if_exception_type(retry_exceptions), + before_sleep=before_sleep, + reraise=True, + ) + + pytestmark = [ pytest.mark.asyncio, pytest.mark.usefixtures("respx_mock"), ] +async def test__botx_method__observability_error_paths_are_isolated( + httpx_client: httpx.AsyncClient, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + observer = BrokenObserver() + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + metadata = BotXRequestMetadata(method="POST", url="https://cts.example/foo") + decorator = method._build_observability_decorator( + metadata=metadata, + observers=(observer,), + ) + + async def runtime_failure() -> httpx.Response: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await decorator(runtime_failure)() + + response = httpx.Response( + HTTPStatus.SERVICE_UNAVAILABLE, + request=httpx.Request("POST", metadata.url), + json={"status": "error", "reason": "busy"}, + ) + + async def botx_failure() -> httpx.Response: + raise InvalidBotXStatusCodeError(response) + + with pytest.raises(InvalidBotXStatusCodeError): + await decorator(botx_failure)() + + retry_event = BotXRetryEvent(1, 2, 0, "timeout") + method._notify_request_retry((observer,), metadata, retry_event) + assert method._format_retry_reason( + cast(RetryCallState, SimpleNamespace(outcome=None)), + ) == "unknown" + outcome = SimpleNamespace(failed=True, exception=lambda: None) + assert method._format_retry_reason( + cast(RetryCallState, SimpleNamespace(outcome=outcome)), + ) == "unknown" + + async def test__botx_method__invalid_botx_status_code_error_raised( httpx_client: httpx.AsyncClient, respx_mock: MockRouter, @@ -286,3 +427,412 @@ async def test__build_botx_url_with_different_bot_cts_urls( # - Assert - assert endpoint.called + + +async def test__botx_method__retries_on_retryable_transport_errors( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + raise httpx.ConnectTimeout("Connection timeout") + + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock(side_effect=responder) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=retry_policy, + retry_request_policy=RetryAllBotXRequestsPolicy(), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + result = await method.execute(payload) + + # - Assert - + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 + + +async def test__botx_method__retries_on_retryable_status_code_and_succeeds( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE) + + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock(side_effect=responder) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=retry_policy, + retry_request_policy=RetryAllBotXRequestsPolicy(), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + result = await method.execute(payload) + + # - Assert - + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 + + +async def test__botx_method__retryable_status_exhausted_returns_original_status_error( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock( + return_value=httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=retry_policy, + retry_request_policy=RetryAllBotXRequestsPolicy(), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await method.execute(payload) + + # - Assert - + assert "failed with code 503" in str(exc.value) + assert endpoint.call_count == 2 + + +async def test__botx_method__without_retry_policy_does_not_retry( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock( + return_value=httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXStatusCodeError): + await method.execute(payload) + + # - Assert - + assert endpoint.call_count == 1 + + +async def test__botx_method__safe_retry_policy_does_not_retry_unsafe_post_by_default( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock( + return_value=httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account], retry_policy=retry_policy), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + with pytest.raises(InvalidBotXStatusCodeError): + await method.execute(payload) + + assert endpoint.call_count == 1 + + +async def test__botx_method__safe_retry_policy_retries_allowlisted_read_only_post( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE) + + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/api/v3/botx/users/by_email").mock( + side_effect=responder, + ) + + method = SearchByEmailMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account], retry_policy=retry_policy), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + result = await method.execute(payload) + + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 + + +async def test__botx_method__operation_name_allowlist_retries_unsafe_post( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + retry_policy = BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ) + + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE) + + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock(side_effect=responder) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=retry_policy, + retry_request_policy=OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={BotXOperation.DIRECT_NOTIFICATION, "FooBarMethod"}, + ), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + result = await method.execute(payload) + + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 + + +async def test__botx_method__metrics_and_tracing_are_opt_in_and_report_retry_events( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_counter = 0 + metrics_collector = ObserverSpy() + tracing_collector = ObserverSpy() + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE) + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock(side_effect=responder) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ), + retry_request_policy=RetryAllBotXRequestsPolicy(), + metrics_collector=metrics_collector, + tracing_collector=tracing_collector, + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + result = await method.execute(payload) + + # - Assert - + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 + + assert len(metrics_collector.start_events) == 1 + assert len(metrics_collector.retry_events) == 1 + assert len(metrics_collector.finish_events) == 1 + assert metrics_collector.start_events[0].operation_name == "FooBarMethod" + assert metrics_collector.start_events[0].is_streaming is False + assert metrics_collector.finish_events[0][1].status_code == HTTPStatus.OK + assert metrics_collector.finish_events[0][1].error is None + + assert len(tracing_collector.start_events) == 1 + assert len(tracing_collector.retry_events) == 1 + assert len(tracing_collector.finish_events) == 1 + + +async def test__botx_method__custom_retry_strategy_overrides_default_tenacity_setup( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE) + return httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ) + + endpoint = respx_mock.post(f"https://{host}/foo/bar").mock(side_effect=responder) + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + # Policy says one attempt, but strategy forces two attempts. + retry_policy=BotXRetryPolicy(max_attempts=1), + retry_request_policy=RetryAllBotXRequestsPolicy(), + retry_strategy=TwoAttemptsRetryStrategy(), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + result = await method.execute(payload) + + # - Assert - + assert result.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.call_count == 2 diff --git a/tests/client/test_botx_method_callback.py b/tests/client/test_botx_method_callback.py index 629f4a2f..a9b4eaa9 100644 --- a/tests/client/test_botx_method_callback.py +++ b/tests/client/test_botx_method_callback.py @@ -240,6 +240,48 @@ async def test__botx_method_callback__orphan_alarm_already_exists( assert "received without a registered handler; buffering" in loguru_caplog.text +async def test__callback_manager__expired_sync_ids_bounded() -> None: + # - Arrange - + import pybotx.bot.callbacks.callback_manager as callback_manager_module + + callbacks_manager = callback_manager_module.CallbackManager( + CallbackMemoryRepo(), + expired_sync_ids_limit=2, + ) + sync_id_1 = UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + sync_id_2 = UUID("d4d3d774-1f90-4b53-9b92-7f3867dbb2f8") + sync_id_3 = UUID("9bb06b2d-cf7c-4a4d-b8b8-8da1b6d66d39") + + # - Act - + callbacks_manager.mark_callback_expired(sync_id_1) + callbacks_manager.mark_callback_expired(sync_id_2) + callbacks_manager.mark_callback_expired(sync_id_3) + + # - Assert - + assert list(callbacks_manager._expired_sync_ids.keys()) == [sync_id_2, sync_id_3] + + +async def test__callback_manager__expired_sync_ids_ttl_cleanup() -> None: + # - Arrange - + import pybotx.bot.callbacks.callback_manager as callback_manager_module + + callbacks_manager = callback_manager_module.CallbackManager( + CallbackMemoryRepo(), + expired_sync_ids_ttl_seconds=0.01, + ) + expired_sync_id = UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + fresh_sync_id = UUID("d4d3d774-1f90-4b53-9b92-7f3867dbb2f8") + + # - Act - + callbacks_manager.mark_callback_expired(expired_sync_id) + await asyncio.sleep(0.05) + callbacks_manager.mark_callback_expired(fresh_sync_id) + + # - Assert - + assert expired_sync_id not in callbacks_manager._expired_sync_ids + assert fresh_sync_id in callbacks_manager._expired_sync_ids + + async def test__botx_method_callback__error_callback_error_handler_called( respx_mock: MockRouter, host: str, @@ -925,3 +967,18 @@ async def test__botx_method_callback__bot_wait_timeouted_callback( assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value) assert "timed out" in str(exc.value) assert endpoint.called + + +async def test__callback_manager__stale_expired_id_is_removed_on_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import pybotx.bot.callbacks.callback_manager as callback_manager_module + + callbacks_manager = callback_manager_module.CallbackManager(CallbackMemoryRepo()) + sync_id = UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + callbacks_manager._expired_sync_ids[sync_id] = 10.0 + calls = iter((0.0, 20.0)) + monkeypatch.setattr(callback_manager_module, "monotonic", lambda: next(calls)) + + assert callbacks_manager._is_expired_sync_id(sync_id) is False + assert sync_id not in callbacks_manager._expired_sync_ids diff --git a/tests/client/test_botx_method_stream.py b/tests/client/test_botx_method_stream.py index 7f274cb5..305dd3e3 100644 --- a/tests/client/test_botx_method_stream.py +++ b/tests/client/test_botx_method_stream.py @@ -6,7 +6,7 @@ from aiofiles.tempfile import NamedTemporaryFile from respx.router import MockRouter -from pybotx import BotAccountWithSecret, InvalidBotXStatusCodeError +from pybotx import BotAccountWithSecret, BotXRetryPolicy, InvalidBotXStatusCodeError from pybotx.async_buffer import AsyncBufferWritable from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.client.botx_method import BotXMethod, response_exception_thrower @@ -135,3 +135,48 @@ async def test__botx_method_stream__succeed( # - Assert - assert await async_buffer.read() == b"Hello, world!\n" assert endpoint.called + + +async def test__botx_method_stream__retries_on_retryable_status_code( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + call_counter = 0 + + def responder(_: httpx.Request) -> httpx.Response: + nonlocal call_counter + call_counter += 1 + if call_counter == 1: + return httpx.Response(HTTPStatus.SERVICE_UNAVAILABLE, content=b"retry") + return httpx.Response(HTTPStatus.OK, content=b"Hello, world!\n") + + endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock( + side_effect=responder, + ) + + method = FooBarStreamMethod( + bot_id, + httpx_client, + BotAccountsStorage( + [bot_account], + retry_policy=BotXRetryPolicy( + max_attempts=2, + initial_delay_seconds=0.0, + max_delay_seconds=0.0, + jitter_seconds=0.0, + ), + ), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + await method.execute(payload, async_buffer) + + # - Assert - + assert await async_buffer.read() == b"Hello, world!\n" + assert endpoint.call_count == 2 diff --git a/tests/client/test_http_config.py b/tests/client/test_http_config.py new file mode 100644 index 00000000..4a203c7a --- /dev/null +++ b/tests/client/test_http_config.py @@ -0,0 +1,186 @@ +from pybotx import ( + AnyOfBotXRetryRequestPolicy, + BotXOperation, + BotXRequestMetadata, + BotXRetryPolicy, + KnownSafeBotXRetryRequestPolicy, + OperationNameAllowlistBotXRetryRequestPolicy, + PathAllowlistBotXRetryRequestPolicy, + RetryAllBotXRequestsPolicy, + SafeBotXRetryRequestPolicy, +) +import pytest +from pybotx.client.http_config import iter_retry_request_policy_warnings +from typing import Any + + +def test__botx_operation__has_str_enum_semantics() -> None: + assert str(BotXOperation.CHAT_INFO) == "ChatInfoMethod" + + +def test__safe_botx_retry_request_policy__retries_get_requests() -> None: + policy = SafeBotXRetryRequestPolicy() + + assert policy.should_retry( + BotXRequestMetadata(method="GET", url="https://cts.example.com/api/v3/botx/chats/list"), + ) + + +def test__safe_botx_retry_request_policy__retries_allowlisted_post_requests() -> None: + policy = SafeBotXRetryRequestPolicy() + + assert policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v3/botx/users/by_email", + ), + ) + + +def test__safe_botx_retry_request_policy__does_not_retry_unsafe_post_requests() -> None: + policy = SafeBotXRetryRequestPolicy() + + assert not policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + ), + ) + + +def test__path_allowlist_botx_retry_request_policy__normalizes_uuid_path_segments() -> None: + policy = PathAllowlistBotXRetryRequestPolicy( + allowed_requests={("GET", "/api/v3/botx/events/{uuid}/status")}, + ) + + assert policy.should_retry( + BotXRequestMetadata( + method="GET", + url=( + "https://cts.example.com/api/v3/botx/events/" + "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3/status" + ), + ), + ) + + +def test__retry_all_botx_requests_policy__retries_everything() -> None: + policy = RetryAllBotXRequestsPolicy() + + assert policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + ), + ) + + +def test__operation_name_allowlist_botx_retry_request_policy__matches_operation_name() -> None: + policy = OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={BotXOperation.CHAT_INFO, BotXOperation.MESSAGE_STATUS}, + ) + + assert policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + operation_name="MessageStatusMethod", + ), + ) + assert not policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + operation_name="DirectNotificationMethod", + ), + ) + + +def test__known_safe_botx_retry_request_policy__retries_known_safe_operation() -> None: + policy = KnownSafeBotXRetryRequestPolicy() + + assert policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v3/botx/users/by_email", + operation_name=BotXOperation.SEARCH_USER_BY_EMAIL_POST, + ), + ) + assert not policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + operation_name=BotXOperation.DIRECT_NOTIFICATION, + ), + ) + + +def test__any_of_botx_retry_request_policy__retries_when_any_nested_policy_matches() -> None: + policy = AnyOfBotXRetryRequestPolicy( + policies=( + SafeBotXRetryRequestPolicy(), + OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={BotXOperation.DIRECT_NOTIFICATION}, + ), + ), + ) + + assert policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + operation_name=BotXOperation.DIRECT_NOTIFICATION, + ), + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_attempts": 0}, + {"initial_delay_seconds": -1}, + {"max_delay_seconds": -1}, + {"initial_delay_seconds": 2, "max_delay_seconds": 1}, + {"jitter_seconds": -1}, + ], +) +def test__botx_retry_policy__rejects_invalid_values(kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError): + BotXRetryPolicy(**kwargs) + + +def test__retry_policies__cover_optional_configuration_and_warning_paths() -> None: + metadata_without_operation = BotXRequestMetadata(method="POST", url="https://cts.test") + operation_policy = OperationNameAllowlistBotXRetryRequestPolicy(operation_names=set()) + assert operation_policy.should_retry(metadata_without_operation) is False + + known_safe = KnownSafeBotXRetryRequestPolicy(extra_operations={"CustomWrite"}) + assert "CustomWrite" in known_safe.get_operation_names() + assert iter_retry_request_policy_warnings(known_safe) + assert iter_retry_request_policy_warnings( + OperationNameAllowlistBotXRetryRequestPolicy(operation_names={"CustomWrite"}), + ) + assert iter_retry_request_policy_warnings( + OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={BotXOperation.CHAT_INFO}, + ), + ) == () + + path_policy = PathAllowlistBotXRetryRequestPolicy( + allowed_requests={("post", "/write")}, + path_normalizer=lambda _url: "/write", + ) + assert path_policy.get_allowed_requests() == frozenset({("POST", "/write")}) + assert iter_retry_request_policy_warnings(path_policy) + + safe = SafeBotXRetryRequestPolicy( + safe_methods={"TRACE"}, + extra_safe_requests={("post", "/write")}, + extra_safe_operation_names={"CustomWrite"}, + path_normalizer=lambda _url: "/write", + ) + assert safe.should_retry(metadata_without_operation) + assert iter_retry_request_policy_warnings(safe) + assert iter_retry_request_policy_warnings( + AnyOfBotXRetryRequestPolicy(policies=(RetryAllBotXRequestsPolicy(),)), + ) diff --git a/tests/client/test_open_telemetry_tracing_collector.py b/tests/client/test_open_telemetry_tracing_collector.py new file mode 100644 index 00000000..8e11a343 --- /dev/null +++ b/tests/client/test_open_telemetry_tracing_collector.py @@ -0,0 +1,350 @@ +import importlib +from dataclasses import dataclass + +import pytest + +from pybotx import ( + BotXRequestMetadata, + BotXRequestResult, + BotXRetryEvent, + OpenTelemetryTracingCollector, +) + + +@dataclass +class _FakeStatus: + status_code: str + description: str | None = None + + +class _FakeStatusCode: + OK = "OK" + ERROR = "ERROR" + + +class _FakeSpanKind: + CLIENT = "CLIENT" + + +class _FakeSpan: + def __init__(self, name: str, kind: str | None = None) -> None: + self.name = name + self.kind = kind + self.attributes: dict[str, object] = {} + self.events: list[tuple[str, dict[str, object]]] = [] + self.exceptions: list[Exception] = [] + self.status: _FakeStatus | None = None + self.ended = False + + def set_attribute(self, key: str, value: object) -> None: + self.attributes[key] = value + + def add_event(self, name: str, attributes: dict[str, object] | None = None) -> None: + self.events.append((name, attributes or {})) + + def record_exception(self, exc: Exception) -> None: + self.exceptions.append(exc) + + def set_status(self, status: _FakeStatus) -> None: + self.status = status + + def end(self) -> None: + self.ended = True + + +class _FakeTracer: + def __init__(self) -> None: + self.spans: list[_FakeSpan] = [] + + def start_span(self, name: str, kind: str | None = None) -> _FakeSpan: + span = _FakeSpan(name, kind) + self.spans.append(span) + return span + + +class _FakeTraceAPI: + Status: object = _FakeStatus + StatusCode: object = _FakeStatusCode + SpanKind: object | None = _FakeSpanKind + + def __init__(self, tracer: _FakeTracer) -> None: + self._tracer = tracer + + def get_tracer(self, *_args: object, **_kwargs: object) -> _FakeTracer: + return self._tracer + + +class _FakeTraceAPIWithoutSpanKind(_FakeTraceAPI): + SpanKind = None + + +class _MinimalTraceAPI(_FakeTraceAPI): + Status = None + StatusCode = None + SpanKind = None + + +def test__open_telemetry_tracing_collector__creates_span_and_retry_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + tracer = _FakeTracer() + trace_api = _FakeTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + collector = OpenTelemetryTracingCollector() + metadata = BotXRequestMetadata( + method="POST", + url="https://cts.example.com:8443/api/v4/botx/notifications/internal", + ) + + # - Act - + collector.on_request_start(metadata) + collector.on_request_retry( + metadata, + BotXRetryEvent( + attempt=1, + max_attempts=3, + sleep_seconds=0.2, + reason="status_code=503", + ), + ) + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=200, duration_ms=125), + ) + + # - Assert - + assert len(tracer.spans) == 1 + span = tracer.spans[0] + assert span.name == "POST /api/v4/botx/notifications/internal" + assert span.kind == _FakeSpanKind.CLIENT + assert span.attributes["http.method"] == "POST" + assert span.attributes["http.url"] == metadata.url + assert span.attributes["http.target"] == "/api/v4/botx/notifications/internal" + assert span.attributes["server.address"] == "cts.example.com" + assert span.attributes["server.port"] == 8443 + assert span.attributes["http.status_code"] == 200 + assert span.attributes["botx.duration_ms"] == 125 + assert span.events == [ + ( + "botx.retry", + { + "retry.attempt": 1, + "retry.max_attempts": 3, + "retry.sleep_seconds": 0.2, + "retry.reason": "status_code=503", + "http.method": "POST", + }, + ), + ] + assert span.status is not None + assert span.status.status_code == _FakeStatusCode.OK + assert span.ended is True + + +def test__open_telemetry_tracing_collector__supports_span_enricher( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + tracer = _FakeTracer() + trace_api = _FakeTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + def span_enricher(span: _FakeSpan, metadata: BotXRequestMetadata) -> None: + span.set_attribute("botx.custom.enriched", True) + span.set_attribute("botx.custom.method", metadata.method) + + collector = OpenTelemetryTracingCollector(span_enricher=span_enricher) + metadata = BotXRequestMetadata( + method="DELETE", + url="https://cts.example.com/api/v3/botx/chats/remove_user", + ) + + # - Act - + collector.on_request_start(metadata) + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=200, duration_ms=10), + ) + + # - Assert - + span = tracer.spans[0] + assert span.attributes["botx.custom.enriched"] is True + assert span.attributes["botx.custom.method"] == "DELETE" + + +def test__open_telemetry_tracing_collector__records_error_in_span( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + tracer = _FakeTracer() + trace_api = _FakeTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + collector = OpenTelemetryTracingCollector() + metadata = BotXRequestMetadata( + method="GET", + url="https://cts.example.com/api/v2/botx/bots/token", + ) + error = RuntimeError("boom") + + # - Act - + collector.on_request_start(metadata) + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=None, duration_ms=31, error=error), + ) + + # - Assert - + span = tracer.spans[0] + assert span.exceptions == [error] + assert span.status is not None + assert span.status.status_code == _FakeStatusCode.ERROR + assert span.status.description == "boom" + assert span.ended is True + + +def test__open_telemetry_tracing_collector__requires_opentelemetry_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + # - Act / Assert - + with pytest.raises(RuntimeError, match="opentelemetry-api"): + OpenTelemetryTracingCollector() + + +def test__open_telemetry_tracing_collector__supports_provider_and_missing_span_kind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tracer = _FakeTracer() + trace_api = _FakeTraceAPIWithoutSpanKind(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + collector = OpenTelemetryTracingCollector(tracer_provider=object()) + metadata = BotXRequestMetadata(method="GET", url="https://cts.example.com") + + collector.on_request_start(metadata) + collector.on_request_finish(metadata, BotXRequestResult(200, 1)) + + assert tracer.spans[0].kind is None + + +def test__open_telemetry_tracing_collector__ends_span_when_enricher_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tracer = _FakeTracer() + trace_api = _FakeTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + def failing_enricher(_span: _FakeSpan, _metadata: BotXRequestMetadata) -> None: + raise RuntimeError("enrichment failed") + + collector = OpenTelemetryTracingCollector(span_enricher=failing_enricher) + metadata = BotXRequestMetadata(method="GET", url="https://cts.example.com") + + with pytest.raises(RuntimeError, match="enrichment failed"): + collector.on_request_start(metadata) + + span = tracer.spans[0] + assert span.ended is True + assert len(span.exceptions) == 1 + assert span.status is not None + assert span.status.status_code == _FakeStatusCode.ERROR + + +def test__open_telemetry_tracing_collector__ignores_hooks_without_active_span( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tracer = _FakeTracer() + trace_api = _FakeTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + collector = OpenTelemetryTracingCollector() + metadata = BotXRequestMetadata(method="GET", url="https://cts.example.com") + + collector.on_request_retry(metadata, BotXRetryEvent(1, 2, 0, "timeout")) + collector.on_request_finish(metadata, BotXRequestResult(None, 1)) + + assert tracer.spans == [] + + +def test__open_telemetry_tracing_collector__supports_minimal_trace_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tracer = _FakeTracer() + trace_api = _MinimalTraceAPI(tracer) + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + metadata = BotXRequestMetadata(method="GET", url="/") + + def failing_enricher(_span: _FakeSpan, _metadata: BotXRequestMetadata) -> None: + raise RuntimeError("failed") + + failing = OpenTelemetryTracingCollector(span_enricher=failing_enricher) + with pytest.raises(RuntimeError, match="failed"): + failing.on_request_start(metadata) + + collector = OpenTelemetryTracingCollector() + collector.on_request_start(metadata) + collector.on_request_finish(metadata, BotXRequestResult(200, 1)) + collector.on_request_start(metadata) + collector.on_request_finish( + metadata, + BotXRequestResult(None, 1, error=RuntimeError("request failed")), + ) + + assert len(tracer.spans) == 3 diff --git a/tests/client/test_prometheus_metrics_collector.py b/tests/client/test_prometheus_metrics_collector.py new file mode 100644 index 00000000..7bfc8e72 --- /dev/null +++ b/tests/client/test_prometheus_metrics_collector.py @@ -0,0 +1,218 @@ +import importlib +from uuid import UUID + +import pytest + +from pybotx import ( + BotXRequestMetadata, + BotXRequestResult, + BotXRetryEvent, + PrometheusMetricsCollector, +) + +prometheus_client = pytest.importorskip("prometheus_client") +CollectorRegistry = prometheus_client.CollectorRegistry + + +def test__prometheus_metrics_collector__requires_prometheus_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "prometheus_client": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + with pytest.raises(RuntimeError, match="prometheus-client"): + PrometheusMetricsCollector() + + +def test__prometheus_metrics_collector__collects_success_request_metrics() -> None: + # - Arrange - + registry = CollectorRegistry() + collector = PrometheusMetricsCollector(registry=registry) + metadata = BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/internal?x=1", + ) + + # - Act - + collector.on_request_start(metadata) + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=200, duration_ms=125), + ) + + # - Assert - + assert registry.get_sample_value( + "pybotx_botx_requests_total", + { + "method": "POST", + "path": "/api/v4/botx/notifications/internal", + "status": "200", + "outcome": "ok", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_botx_request_latency_seconds_count", + { + "method": "POST", + "path": "/api/v4/botx/notifications/internal", + "status": "200", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_botx_request_latency_seconds_sum", + { + "method": "POST", + "path": "/api/v4/botx/notifications/internal", + "status": "200", + }, + ) == pytest.approx(0.125) + + +def test__prometheus_metrics_collector__collects_error_metrics() -> None: + # - Arrange - + registry = CollectorRegistry() + collector = PrometheusMetricsCollector(registry=registry) + metadata = BotXRequestMetadata(method="GET", url="https://cts.example.com/foo/bar") + error = RuntimeError("boom") + + # - Act - + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=None, duration_ms=30, error=error), + ) + + # - Assert - + assert registry.get_sample_value( + "pybotx_botx_requests_total", + { + "method": "GET", + "path": "/foo/bar", + "status": "none", + "outcome": "error", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_botx_request_errors_total", + { + "method": "GET", + "path": "/foo/bar", + "status": "none", + "error_type": "RuntimeError", + }, + ) == 1.0 + + +def test__prometheus_metrics_collector__collects_retry_metrics_with_normalized_labels() -> None: + # - Arrange - + registry = CollectorRegistry() + collector = PrometheusMetricsCollector(registry=registry) + bot_id = UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + metadata = BotXRequestMetadata( + method="GET", + url=f"https://cts.example.com/api/v2/botx/bots/{bot_id}/token", + ) + + # - Act - + collector.on_request_retry( + metadata, + BotXRetryEvent( + attempt=1, + max_attempts=3, + sleep_seconds=0.1, + reason="ConnectTimeout: Connection timeout", + ), + ) + + # - Assert - + assert registry.get_sample_value( + "pybotx_botx_request_retries_total", + { + "method": "GET", + "path": "/api/v2/botx/bots/{uuid}/token", + "reason": "ConnectTimeout", + }, + ) == 1.0 +def test__prometheus_metrics_collector__supports_custom_normalizers() -> None: + # - Arrange - + registry = CollectorRegistry() + + def path_normalizer(_: str) -> str: + return "/custom/path" + + def reason_normalizer(_: str) -> str: + return "custom_reason" + + collector = PrometheusMetricsCollector( + registry=registry, + path_normalizer=path_normalizer, + reason_normalizer=reason_normalizer, + ) + metadata = BotXRequestMetadata( + method="PATCH", + url="https://cts.example.com/very/dynamic/path", + ) + + # - Act - + collector.on_request_retry( + metadata, + BotXRetryEvent( + attempt=1, + max_attempts=2, + sleep_seconds=0.1, + reason="NetworkError: boom", + ), + ) + collector.on_request_finish( + metadata, + BotXRequestResult(status_code=204, duration_ms=12), + ) + + # - Assert - + assert registry.get_sample_value( + "pybotx_botx_request_retries_total", + { + "method": "PATCH", + "path": "/custom/path", + "reason": "custom_reason", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_botx_requests_total", + { + "method": "PATCH", + "path": "/custom/path", + "status": "204", + "outcome": "ok", + }, + ) == 1.0 + + +def test__prometheus_metrics_collector__keeps_status_retry_reason() -> None: + registry = CollectorRegistry() + collector = PrometheusMetricsCollector( + registry=registry, + metric_prefix="test_status_reason", + latency_buckets=(0.01, 0.1), + ) + metadata = BotXRequestMetadata(method="GET", url="https://cts.example.com") + + collector.on_request_retry( + metadata, + BotXRetryEvent( + attempt=1, + max_attempts=2, + sleep_seconds=0, + reason="status_code=503", + ), + ) + + assert registry.get_sample_value( + "test_status_reason_request_retries_total", + {"method": "GET", "path": "/", "reason": "status_code=503"}, + ) == 1.0 diff --git a/tests/client/users_api/test_search_user_by_emails.py b/tests/client/users_api/test_search_user_by_emails.py index 72d61425..3fefc536 100644 --- a/tests/client/users_api/test_search_user_by_emails.py +++ b/tests/client/users_api/test_search_user_by_emails.py @@ -85,3 +85,45 @@ async def test__search_user_by_email_without_data__succeed( # - Assert - assert_deep_equal(users, [user_from_search_without_data]) assert endpoint.called + + +async def test__search_user_by_email_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_emails = ["ad_user@cts.com"] + + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={ + "emails": user_emails, + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json]), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + users = await bot.search_user_by_emails( + bot_id=bot_id, + emails=user_emails, + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(users, [user_from_search_with_data]) + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py index 51a47cc2..a0917033 100644 --- a/tests/client/users_api/test_search_user_by_huid.py +++ b/tests/client/users_api/test_search_user_by_huid.py @@ -82,6 +82,43 @@ async def test__search_user_by_huid__succeed( assert endpoint.called +async def test__search_user_by_huid_with_numeric_ip_phone__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_from_search_with_data_json["ip_phone"] = 1271020 + + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", + params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ) + + # - Assert - + assert user.ip_phone == "1271020" + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called + + async def test__search_user_by_huid_without_data__succeed( respx_mock: MockRouter, host: str, @@ -114,3 +151,43 @@ async def test__search_user_by_huid_without_data__succeed( # - Assert - assert_deep_equal(user, user_from_search_without_data) assert endpoint.called + + +async def test__search_user_by_huid_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", + params={ + "user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1", + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_login.py b/tests/client/users_api/test_search_user_by_login.py index 75ea05cb..90f485fb 100644 --- a/tests/client/users_api/test_search_user_by_login.py +++ b/tests/client/users_api/test_search_user_by_login.py @@ -117,3 +117,45 @@ async def test__search_user_by_ad_without_data__succeed( # - Assert - assert_deep_equal(user, user_from_search_without_data) assert endpoint.called + + +async def test__search_user_by_ad_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_login", + params={ + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_ad( + bot_id=bot_id, + ad_login="ad_user_login", + ad_domain="cts.com", + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_other_id.py b/tests/client/users_api/test_search_user_by_other_id.py index df8d3465..b55b85c4 100644 --- a/tests/client/users_api/test_search_user_by_other_id.py +++ b/tests/client/users_api/test_search_user_by_other_id.py @@ -114,3 +114,43 @@ async def test__search_user_by_other_id_without_data__succeed( # - Assert - assert_deep_equal(user, user_from_search_without_data) assert endpoint.called + + +async def test__search_user_by_other_id_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_other_id", + params={ + "other_id": "some_id", + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_other_id( + bot_id=bot_id, + other_id="some_id", + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called diff --git a/tests/client/users_api/test_trusted_search.py b/tests/client/users_api/test_trusted_search.py new file mode 100644 index 00000000..673250f6 --- /dev/null +++ b/tests/client/users_api/test_trusted_search.py @@ -0,0 +1,81 @@ +from collections.abc import Callable +from typing import Any +from uuid import UUID + +import pytest + +from pybotx.client.users_api.search_user_by_email import ( + BotXAPISearchUserByEmailRequestPayload, +) +from pybotx.client.users_api.search_user_by_emails import ( + BotXAPISearchUserByEmailsRequestPayload, +) +from pybotx.client.users_api.search_user_by_huid import ( + BotXAPISearchUserByHUIDRequestPayload, +) +from pybotx.client.users_api.search_user_by_login import ( + BotXAPISearchUserByLoginRequestPayload, +) +from pybotx.client.users_api.search_user_by_other_id import ( + BotXAPISearchUserByOtherIdRequestPayload, +) + + +@pytest.mark.parametrize( + "payload_factory", + [ + lambda: BotXAPISearchUserByEmailRequestPayload.from_domain( + "user@example.com", partial_response=True + ), + lambda: BotXAPISearchUserByEmailsRequestPayload.from_domain( + ["user@example.com"], partial_response=True + ), + lambda: BotXAPISearchUserByHUIDRequestPayload.from_domain( + UUID("00000000-0000-0000-0000-000000000001"), + partial_response=True, + ), + lambda: BotXAPISearchUserByLoginRequestPayload.from_domain( + "user", "example.com", partial_response=True + ), + lambda: BotXAPISearchUserByOtherIdRequestPayload.from_domain( + "external-id", partial_response=True + ), + ], +) +def test__partial_response_without_trusted_search__raises( + payload_factory: Callable[[], Any], +) -> None: + with pytest.raises( + ValueError, + match="`partial_response=True` requires `trusts_search=True`", + ): + payload_factory() + + +@pytest.mark.parametrize( + "payload_factory", + [ + lambda: BotXAPISearchUserByEmailRequestPayload.from_domain( + "user@example.com", trusts_search=True + ), + lambda: BotXAPISearchUserByEmailsRequestPayload.from_domain( + ["user@example.com"], trusts_search=True + ), + lambda: BotXAPISearchUserByHUIDRequestPayload.from_domain( + UUID("00000000-0000-0000-0000-000000000001"), trusts_search=True + ), + lambda: BotXAPISearchUserByLoginRequestPayload.from_domain( + "user", "example.com", trusts_search=True + ), + lambda: BotXAPISearchUserByOtherIdRequestPayload.from_domain( + "external-id", trusts_search=True + ), + ], +) +def test__trusted_search_without_partial_response__serializes_only_enabled_flag( + payload_factory: Callable[[], Any], +) -> None: + payload = payload_factory().jsonable_dict() + + assert payload["trusts_search"] is True + assert "partial_response" not in payload diff --git a/tests/client/users_api/test_update_user_profile.py b/tests/client/users_api/test_update_user_profile.py index da713334..06359d93 100644 --- a/tests/client/users_api/test_update_user_profile.py +++ b/tests/client/users_api/test_update_user_profile.py @@ -5,7 +5,10 @@ import pytest from respx.router import MockRouter -from pybotx.client.exceptions.users import InvalidProfileDataError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserProfileUpdateUnavailableError, +) from pybotx.models.attachments import AttachmentImage from pybotx.models.enums import AttachmentTypes from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload @@ -168,3 +171,46 @@ async def test__update_user_profile__invalid_profile_data_error( # - Assert - assert endpoint.called + + +@pytest.mark.parametrize( + "reason", + [ + "error_from_ad_phonebook_service", + "unexpected_error", + ], +) +async def test__update_user_profile__service_unavailable_error( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, + reason: str, +) -> None: + # - Arrange - + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", + json={ + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload(reason), + HTTPStatus.SERVICE_UNAVAILABLE, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(UserProfileUpdateUnavailableError) as exc: + await bot.update_user_profile( + bot_id=bot_id, + user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ) + + # - Assert - + assert reason in str(exc.value) + assert endpoint.called diff --git a/tests/client/users_api/test_users_as_csv.py b/tests/client/users_api/test_users_as_csv.py index 95315b2c..95b98fdc 100644 --- a/tests/client/users_api/test_users_as_csv.py +++ b/tests/client/users_api/test_users_as_csv.py @@ -138,3 +138,56 @@ async def test__users_as_csv__succeed( ), ], ) + + +async def test__users_as_csv_with_documented_columns_only__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/users_as_csv", + params={"cts_user": True, "unregistered": True, "botx": False}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + response_json=None, + status=HTTPStatus.OK, + response_content=( + b"HUID,AD Login,Domain,AD E-mail,Name,Sync source,Active,Kind,Company,Department,Position,Manager,Manager HUID\n" + b"dbc8934f-d0d7-4a9e-89df-d45c137a851c,test_user_17,cts.example.com,,test_user_17,ad,true,cts_user,Company,Department,Position,Manager John,13a6909c-bce1-4dbf-8359-efb7ef8e5b34\n" + ), + ) + + users_from_csv = [] + + async with bot_factory() as bot: + async with bot.users_as_csv(bot_id=bot_id) as users: + async for user in users: + users_from_csv.append(user) + + assert endpoint.called + assert_deep_equal( + users_from_csv, + [ + UserFromCSV( + huid=UUID("dbc8934f-d0d7-4a9e-89df-d45c137a851c"), + ad_login="test_user_17", + ad_domain="cts.example.com", + username="test_user_17", + sync_source=SyncSourceTypes.AD, + active=True, + user_kind=UserKinds.CTS_USER, + email=None, + company="Company", + department="Department", + position="Position", + manager="Manager John", + manager_huid=UUID("13a6909c-bce1-4dbf-8359-efb7ef8e5b34"), + ), + ], + ) diff --git a/tests/client/voex_api/__init__.py b/tests/client/voex_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/voex_api/test_call.py b/tests/client/voex_api/test_call.py new file mode 100644 index 00000000..887e50c4 --- /dev/null +++ b/tests/client/voex_api/test_call.py @@ -0,0 +1,113 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + lifespan_wrapper, +) +from pybotx.client.voex_api.exceptions import CallNotFoundError +from pybotx.models.call import Call + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_call__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + member1 = "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4" + member2 = "6fa5f1e9-1453-0ad7-2d6d-b791467e382a" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/calls/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "call_id": call_id, + "members": [ + member1, + member2, + ], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + call = await bot.get_call( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert call == Call( + id=UUID(call_id), + members=[ + UUID(member1), + UUID(member2), + ], + ) + + assert endpoint.called + + +async def test__get_call__call_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/calls/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "call_id": call_id, + }, + "errors": ["Call with specified call_id not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(CallNotFoundError) as exc: + await bot.get_call( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert "not_found" in str(exc.value) + assert endpoint.called diff --git a/tests/client/voex_api/test_conference.py b/tests/client/voex_api/test_conference.py new file mode 100644 index 00000000..03c3aa1b --- /dev/null +++ b/tests/client/voex_api/test_conference.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + lifespan_wrapper, +) +from pybotx.client.voex_api.exceptions import ConferenceNotFoundError +from pybotx.models.conference import Conference + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +@pytest.mark.parametrize("link", ["https://example.com/join", None]) +async def test__get_conference__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + link: str | None, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + member1 = "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4" + member2 = "6fa5f1e9-1453-0ad7-2d6d-b791467e382a" + + name = "Test Conference" + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/conferences/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "call_id": call_id, + "name": name, + "link": link, + "members": [ + member1, + member2, + ], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + call = await bot.get_conference( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert call == Conference( + id=UUID(call_id), + name=name, + link=link, + members=[ + UUID(member1), + UUID(member2), + ], + ) + + assert endpoint.called + + +async def test__get_conference__call_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "4f60d594-21cb-52b4-8b04-8eb73c05085e" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/conferences/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "call_id": call_id, + }, + "errors": ["Conference with specified call_id not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ConferenceNotFoundError) as exc: + await bot.get_conference( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert "not_found" in str(exc.value) + assert endpoint.called diff --git a/tests/fixtures/users_api.py b/tests/fixtures/users_api.py index 5196615f..d26b3368 100644 --- a/tests/fixtures/users_api.py +++ b/tests/fixtures/users_api.py @@ -13,6 +13,16 @@ def user_from_search_with_data_json() -> dict[str, Any]: "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", "ad_login": "ad_user_login", "ad_domain": "cts.com", + "avatar": ( + "https://test-cts.ru/uploads/profile_avatar/fa13e946-121b-52ce-aa92-677b51d19d83/" + "3ebb64d5e2f54e1ab9a3f2ff8096f55a.jpg" + "?v=1780399650402&sid=a619fcfa-a19b-5256-a592-9b0e75ca0896" + ), + "avatar_preview": ( + "https://test-cts.ru/uploads/profile_avatar/fa13e946-121b-52ce-aa92-677b51d19d83/" + "0a1294c14df84a1ea13c62483ec6cd0f.jpg?" + "v=1780399650402&sid=a619fcfa-a19b-5256-a592-9b0e75ca0896" + ), "name": "Bob", "company": "Bobs Co", "company_position": "Director", @@ -40,6 +50,16 @@ def user_from_search_with_data() -> UserFromSearch: huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), ad_login="ad_user_login", ad_domain="cts.com", + avatar=( + "https://test-cts.ru/uploads/profile_avatar/fa13e946-121b-52ce-aa92-677b51d19d83/" + "3ebb64d5e2f54e1ab9a3f2ff8096f55a.jpg" + "?v=1780399650402&sid=a619fcfa-a19b-5256-a592-9b0e75ca0896" + ), + avatar_preview=( + "https://test-cts.ru/uploads/profile_avatar/fa13e946-121b-52ce-aa92-677b51d19d83/" + "0a1294c14df84a1ea13c62483ec6cd0f.jpg?" + "v=1780399650402&sid=a619fcfa-a19b-5256-a592-9b0e75ca0896" + ), username="Bob", company="Bobs Co", company_position="Director", @@ -95,6 +115,8 @@ def user_from_search_without_data() -> UserFromSearch: huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), ad_login="ad_user_login", ad_domain="cts.com", + avatar=None, + avatar_preview=None, username="Bob", company="Bobs Co", company_position="Director", diff --git a/tests/models/test_botx_api_markup.py b/tests/models/test_botx_api_markup.py index 850d4639..0d79c67b 100644 --- a/tests/models/test_botx_api_markup.py +++ b/tests/models/test_botx_api_markup.py @@ -1,11 +1,14 @@ import json +import warnings from typing import Any +from pybotx import BubbleMarkup from pybotx.models.message.markup import ( BotXAPIMarkup, BotXAPIButton, BotXAPIButtonOptions, + api_markup_from_domain, ) @@ -114,3 +117,35 @@ def test_botx_api_markup_jsonable_dict() -> None: ] ] assert jsonable_dict == expected_dict + + +def test_botx_api_markup_link_button_without_command() -> None: + # - Arrange - + markup = BubbleMarkup() + markup.add_button( + label="Open me", + link="https://example.com", + ) + + # - Act - + with warnings.catch_warnings(record=True) as captured_warnings: + warnings.simplefilter("always") + jsonable_dict = api_markup_from_domain(markup).jsonable_dict() + + # - Assert - + expected_dict: list[list[dict[str, Any]]] = [ + [ + { + "label": "Open me", + "data": {}, + "opts": { + "silent": True, + "align": "center", + "handler": "client", + "link": "https://example.com", + }, + } + ] + ] + assert jsonable_dict == expected_dict + assert not captured_warnings diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py index 7cadc43b..038aa26e 100644 --- a/tests/models/test_enums.py +++ b/tests/models/test_enums.py @@ -34,7 +34,7 @@ def test__convert_chat_type_from_domain__unsupported_chat_type_raises_error() -> def test__convert_chat_type_to_domain__notes_maps_to_personal_chat() -> None: assert convert_chat_type_to_domain(APIChatTypes.NOTES) == ChatTypes.PERSONAL_CHAT - assert convert_chat_type_to_domain(APIChatTypes.VOEX_CALL) == ChatTypes.GROUP_CHAT + assert convert_chat_type_to_domain(APIChatTypes.VOEX_CALL) == ChatTypes.VOEX_CALL assert convert_chat_type_to_domain("notes") == ChatTypes.PERSONAL_CHAT diff --git a/tests/models/test_enums_property.py b/tests/models/test_enums_property.py index 257353ec..fc5a512d 100644 --- a/tests/models/test_enums_property.py +++ b/tests/models/test_enums_property.py @@ -32,7 +32,11 @@ API_SYNC_SOURCE_VALUES = [sync_type.value for sync_type in APISyncSourceTypes] -@given(st.sampled_from(list(ChatTypes))) +@given( + st.sampled_from( + [chat_type for chat_type in ChatTypes if chat_type is not ChatTypes.VOEX_CALL] + ) +) def test__convert_chat_type_roundtrip__property(chat_type: ChatTypes) -> None: api_type = convert_chat_type_from_domain(chat_type) assert convert_chat_type_to_domain(api_type) == chat_type diff --git a/tests/system_events/test_cts_login.py b/tests/system_events/test_cts_login.py index 1c13022b..0e66f6b4 100644 --- a/tests/system_events/test_cts_login.py +++ b/tests/system_events/test_cts_login.py @@ -1,4 +1,5 @@ from uuid import UUID +from typing import Any import pytest @@ -6,10 +7,13 @@ Bot, BotAccount, BotAccountWithSecret, + BotCommandProcessingConfig, CTSLoginEvent, HandlerCollector, + IncomingMessage, lifespan_wrapper, ) +from pybotx.bot.contextvars import chat_id_var pytestmark = [ pytest.mark.asyncio, @@ -86,3 +90,117 @@ async def cts_login_handler(event: CTSLoginEvent, bot: Bot) -> None: raw_command=None, huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"), ) + + +async def test__cts_login__chat_context_not_leaked_from_previous_command( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + incoming_payload: dict[str, Any] = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "/hello", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [], + "async_files": [], + "entities": [], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": None, + "device_software": None, + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "user_udid": None, + "username": None, + }, + "proto_version": 4, + } + cts_login_payload: dict[str, Any] = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:cts_login", + "data": { + "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20", + "cts_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + "command_type": "system", + "metadata": {}, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": None, + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": None, + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + chat_id_seen_in_message_handler: UUID | None = None + chat_id_lookup_failed_in_system_event = False + + @collector.default_message_handler + async def default_handler(_message: IncomingMessage, _bot: Bot) -> None: + nonlocal chat_id_seen_in_message_handler + chat_id_seen_in_message_handler = chat_id_var.get() + + @collector.cts_login + async def cts_login_handler(_event: CTSLoginEvent, _bot: Bot) -> None: + nonlocal chat_id_lookup_failed_in_system_event + try: + chat_id_var.get() + except LookupError: + chat_id_lookup_failed_in_system_event = True + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=10, + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(incoming_payload, verify_request=False) + bot.async_execute_raw_bot_command(cts_login_payload, verify_request=False) + + # - Assert - + assert chat_id_seen_in_message_handler == UUID("30dc1980-643a-00ad-37fc-7cc10d74e935") + assert chat_id_lookup_failed_in_system_event diff --git a/tests/test_attachments.py b/tests/test_attachments.py index a3a87b6d..f4066c80 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -21,10 +21,12 @@ AttachmentVideo, AttachmentVoice, BotAPIAttachment, + BotXAPIAttachment, Contact, IncomingAttachment, Link, Location, + OutgoingAttachment, convert_api_attachment_to_domain, ) @@ -81,6 +83,21 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: assert read_content == b"Hello, world!\n" +async def test__botx_api_attachment__uppercase_file_extension_mimetype() -> None: + # - Arrange - + attachment = OutgoingAttachment( + content=b"Hello, world!", + filename="image.PNG", + ) + + # - Act - + api_attachment = BotXAPIAttachment.from_file_attachment(attachment) + + # - Assert - + assert api_attachment.file_name == "image.PNG" + assert api_attachment.data == "data:image/png;base64,SGVsbG8sIHdvcmxkIQ==" + + API_AND_DOMAIN_NON_FILE_ATTACHMENTS = ( ( { diff --git a/tests/test_bot_constructing.py b/tests/test_bot_constructing.py index 68cd8ee5..e8a7185e 100644 --- a/tests/test_bot_constructing.py +++ b/tests/test_bot_constructing.py @@ -1,6 +1,31 @@ +import asyncio +from typing import Any, cast +from uuid import UUID + +import httpx import pytest -from pybotx import Bot, BotAccountWithSecret, HandlerCollector +from pybotx import ( + Bot, + BotAccountWithSecret, + BotXOperation, + BotCommandProcessingConfig, + BotXRequestMetadata, + BotXRequestResult, + BotXRetryPolicy, + BotXRetryStrategy, + BotXRetryEvent, + HandlerCollector, + InMemoryIngressMetricsCollector, + IngressCommandMetadata, + IngressCommandResult, + KnownSafeBotXRetryRequestPolicy, + OperationNameAllowlistBotXRetryRequestPolicy, + RetryAllBotXRequestsPolicy, + SafeBotXRetryRequestPolicy, + build_default_httpx_limits, + build_default_httpx_timeout, +) def test__bot__empty_collectors_warning( @@ -22,3 +47,445 @@ def test__bot__empty_bot_accounts_warning( # - Assert - assert "Bot has no bot accounts" in loguru_caplog.text + + +def test__bot__default_httpx_client_created_with_explicit_limits_and_timeout( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + expected_timeout = build_default_httpx_timeout() + expected_limits = build_default_httpx_limits() + + # - Act - + bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Assert - + assert bot._httpx_client.timeout == expected_timeout + transport = cast(Any, bot._httpx_client._transport) + assert transport._pool._max_connections == ( + expected_limits.max_connections + ) + assert transport._pool._max_keepalive_connections == ( + expected_limits.max_keepalive_connections + ) + + +def test__bot__custom_httpx_client_with_http_settings_raises_value_error( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + custom_httpx_client = httpx.AsyncClient() + + try: + # - Act / Assert - + with pytest.raises(ValueError): + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + httpx_client=custom_httpx_client, + httpx_timeout=httpx.Timeout(timeout=1.0), + ) + finally: + asyncio.run(custom_httpx_client.aclose()) + + +def test__bot__passes_retry_policy_to_accounts_storage( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + retry_policy = BotXRetryPolicy(max_attempts=2) + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=retry_policy, + ) + + # - Assert - + assert bot._bot_accounts_storage.get_retry_policy() == retry_policy + + +def test__bot__passes_retry_strategy_to_accounts_storage( + bot_account: BotAccountWithSecret, +) -> None: + class _RetryStrategy(BotXRetryStrategy): + def build_retrying(self, **_kwargs: Any) -> Any: + raise NotImplementedError + + retry_strategy = _RetryStrategy() + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_strategy=retry_strategy, + ) + + # - Assert - + assert bot._bot_accounts_storage.get_retry_strategy() is retry_strategy + + +def test__bot__uses_safe_retry_request_policy_by_default_when_retry_enabled( + bot_account: BotAccountWithSecret, +) -> None: + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + ) + + # - Assert - + assert isinstance( + bot._bot_accounts_storage.get_retry_request_policy(), + SafeBotXRetryRequestPolicy, + ) + + +def test__bot__passes_retry_request_policy_to_accounts_storage( + bot_account: BotAccountWithSecret, +) -> None: + retry_request_policy = RetryAllBotXRequestsPolicy() + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_request_policy=retry_request_policy, + ) + + # - Assert - + assert bot._bot_accounts_storage.get_retry_request_policy() is retry_request_policy + + +def test__bot__known_safe_retry_request_policy_is_supported( + bot_account: BotAccountWithSecret, +) -> None: + retry_request_policy = KnownSafeBotXRetryRequestPolicy() + + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_request_policy=retry_request_policy, + ) + + assert bot._bot_accounts_storage.get_retry_request_policy() is retry_request_policy + + +def test__bot__retry_disabled_by_default( + bot_account: BotAccountWithSecret, +) -> None: + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + # - Assert - + assert bot._bot_accounts_storage.get_retry_policy() is None + assert bot._bot_accounts_storage.get_retry_request_policy() is None + + +def test__bot__retry_all_policy_logs_warning( + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_request_policy=RetryAllBotXRequestsPolicy(), + ) + + assert "RetryAllBotXRequestsPolicy retries all BotX requests" in loguru_caplog.text + + +def test__bot__operation_allowlist_with_unsafe_operation_logs_warning( + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_request_policy=OperationNameAllowlistBotXRetryRequestPolicy( + operation_names={BotXOperation.DIRECT_NOTIFICATION}, + ), + ) + + assert "OperationNameAllowlistBotXRetryRequestPolicy allows operations" in ( + loguru_caplog.text + ) + + +def test__bot__known_safe_retry_policy_does_not_log_warning( + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + retry_policy=BotXRetryPolicy(max_attempts=2), + retry_request_policy=KnownSafeBotXRetryRequestPolicy(), + ) + + assert "known-safe catalog" not in loguru_caplog.text + + +def test__bot__observability_disabled_by_default( + bot_account: BotAccountWithSecret, +) -> None: + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + # - Assert - + assert list(bot._bot_accounts_storage.iter_request_observers()) == [] + + +def test__bot__ingress_observability_enabled_by_default( + bot_account: BotAccountWithSecret, +) -> None: + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + # - Assert - + assert isinstance(bot._ingress_metrics_collector, InMemoryIngressMetricsCollector) + assert bot._handler_collector._ingress_metrics_collector is bot._ingress_metrics_collector + + +def test__bot__passes_custom_ingress_metrics_collector( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + class _IngressCollector: + def on_queue_depth(self, queue_depth: int) -> None: + pass + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + pass + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + pass + + ingress_metrics_collector = _IngressCollector() + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ingress_metrics_collector=ingress_metrics_collector, + ) + + # - Assert - + assert bot._ingress_metrics_collector is ingress_metrics_collector + assert bot._handler_collector._ingress_metrics_collector is ingress_metrics_collector + + +def test__bot__passes_metrics_and_tracing_collectors( + bot_account: BotAccountWithSecret, +) -> None: + class _Observer: + def on_request_start(self, metadata: BotXRequestMetadata) -> None: + pass + + def on_request_retry( + self, + metadata: BotXRequestMetadata, + retry_event: BotXRetryEvent, + ) -> None: + pass + + def on_request_finish( + self, + metadata: BotXRequestMetadata, + result: BotXRequestResult, + ) -> None: + pass + + metrics_collector = _Observer() + tracing_collector = _Observer() + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + metrics_collector=metrics_collector, + tracing_collector=tracing_collector, + ) + + # - Assert - + assert list(bot._bot_accounts_storage.iter_request_observers()) == [ + metrics_collector, + tracing_collector, + ] + + +def test__bot__passes_command_processing_config_to_handler_collector( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + command_processing_config = BotCommandProcessingConfig( + max_concurrency=3, + max_queue_size=50, + ) + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + command_processing_config=command_processing_config, + ) + + # - Assert - + assert ( + bot._handler_collector._command_processing_config + == command_processing_config + ) + + +def test__bot__passes_expired_sync_ids_config_to_callback_manager( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + expired_sync_ids_ttl_seconds = 120.0 + expired_sync_ids_limit = 20_000 + + # - Act - + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + expired_sync_ids_ttl_seconds=expired_sync_ids_ttl_seconds, + expired_sync_ids_limit=expired_sync_ids_limit, + ) + + # - Assert - + assert ( + bot._callbacks_manager._expired_sync_ids_ttl_seconds + == expired_sync_ids_ttl_seconds + ) + assert bot._callbacks_manager._expired_sync_ids_limit == expired_sync_ids_limit + + +def test__bot__invalid_expired_sync_ids_ttl_raises_value_error( + bot_account: BotAccountWithSecret, +) -> None: + with pytest.raises(ValueError, match="Expired sync ids ttl should be greater than 0"): + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + expired_sync_ids_ttl_seconds=0, + ) + + +def test__bot__invalid_expired_sync_ids_limit_raises_value_error( + bot_account: BotAccountWithSecret, +) -> None: + with pytest.raises( + ValueError, + match="Expired sync ids limit should be greater than 0", + ): + Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + expired_sync_ids_limit=0, + ) + + +def test__bot__extract_request_id_prefers_headers( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + assert bot._extract_request_id( + {"sync_id": "sync-id"}, + {"X-Request-Id": "header-request-id"}, + ) == "header-request-id" + assert bot._extract_request_id( + {"sync_id": "sync-id"}, + None, + ) == "sync-id" + assert bot._extract_request_id(None, None) is None + + +def test__bot__extract_trace_id_from_headers_and_fallback( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + assert bot._extract_trace_id({"X-Trace-Id": "trace-id"}, "fallback") == "trace-id" + assert ( + bot._extract_trace_id( + {"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}, + "fallback", + ) + == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + assert bot._extract_trace_id({"b3": "aaaaaaaaaaaaaaaa-1-0"}, "fallback") == ( + "aaaaaaaaaaaaaaaa" + ) + assert bot._extract_trace_id({"traceparent": "invalid", "b3": "invalid"}, "fallback") == ( + "fallback" + ) + + +def test__bot__extract_bot_and_chat_id_from_payload( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + assert bot._extract_bot_id({"bot_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}) == UUID( + "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + ) + assert bot._extract_bot_id({"bot_id": 1}) is None + assert bot._extract_chat_id( + {"from": {"group_chat_id": "d4d3d774-1f90-4b53-9b92-7f3867dbb2f8"}}, + ) == UUID("d4d3d774-1f90-4b53-9b92-7f3867dbb2f8") + assert bot._extract_chat_id( + {"group_chat_id": "2fa2f2a8-22de-4ba7-8da1-7faeb2975bb0"}, + ) == UUID("2fa2f2a8-22de-4ba7-8da1-7faeb2975bb0") + assert bot._extract_chat_id({"from": "not-a-mapping"}) is None + + +def test__bot__normalize_correlation_value( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + assert bot._normalize_correlation_value(" value ") == "value" + assert bot._normalize_correlation_value("") is None + assert bot._normalize_correlation_value(None) is None + assert bot._normalize_correlation_value("a" * 256) == "a" * 128 diff --git a/tests/test_bot_edge_cases.py b/tests/test_bot_edge_cases.py new file mode 100644 index 00000000..0caa4164 --- /dev/null +++ b/tests/test_bot_edge_cases.py @@ -0,0 +1,67 @@ +import asyncio +from typing import Any +from uuid import UUID + +import pytest + +from pybotx import Bot, BotXRetryPolicy, HandlerCollector +from pybotx.bot.bot_accounts_storage import BotAccountsStorage + + +@pytest.mark.asyncio +async def test__dispatch_raw_command__supports_wait_modes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + completed = False + + async def execute() -> None: + nonlocal completed + completed = True + + def start(_self: Bot, *_args: Any, **_kwargs: Any) -> asyncio.Task[None]: + return asyncio.create_task(execute()) + + monkeypatch.setattr(Bot, "async_execute_raw_bot_command", start) + + await bot.dispatch_raw_command({}, wait=True) + assert completed is True + + completed = False + await bot.dispatch_raw_command({}, wait=False) + await asyncio.sleep(0) + assert completed is True + await bot.shutdown() + + +@pytest.mark.asyncio +async def test__bot_correlation_extractors_cover_invalid_and_optional_inputs() -> None: + bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + uuid = UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + + assert bot._extract_trace_id( + {"traceparent": "00-not-a-valid-trace-id-span-01", "b3": "also-invalid"}, + "fallback", + ) == "fallback" + assert bot._extract_bot_id(None) is None + assert bot._extract_chat_id(None) is None + assert bot._extract_uuid(uuid) is uuid + assert bot._extract_uuid("not-a-uuid") is None + await bot.shutdown() + + +@pytest.mark.asyncio +async def test__bot_allows_storage_without_resolved_retry_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + BotAccountsStorage, + "get_retry_request_policy", + lambda _self: None, + ) + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[], + retry_policy=BotXRetryPolicy(), + ) + await bot.shutdown() diff --git a/tests/test_command_processing.py b/tests/test_command_processing.py new file mode 100644 index 00000000..dafaf2fb --- /dev/null +++ b/tests/test_command_processing.py @@ -0,0 +1,19 @@ +import pytest + +from pybotx import BotCommandProcessingConfig + + +def test__command_processing_config__invalid_max_concurrency_raises_value_error() -> None: + with pytest.raises( + ValueError, + match="max concurrency should be greater than 0", + ): + BotCommandProcessingConfig(max_concurrency=0) + + +def test__command_processing_config__invalid_max_queue_size_raises_value_error() -> None: + with pytest.raises( + ValueError, + match="max queue size should be greater than 0", + ): + BotCommandProcessingConfig(max_queue_size=0) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 9b69e837..95877707 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -5,25 +5,18 @@ import httpx import pytest -from fastapi import APIRouter, Depends, FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi import FastAPI from fastapi.testclient import TestClient -from loguru import logger from respx.router import MockRouter from pybotx import ( Bot, BotAccountWithSecret, + FastAPIBotAppConfig, HandlerCollector, IncomingMessage, SmartAppEvent, - UnknownBotAccountError, - UnverifiedRequestError, - build_bot_disabled_response, - build_command_accepted_response, -) -from pybotx.bot.api.responses.unverified_request import ( - build_unverified_request_response, + create_fastapi_bot_app, ) from pybotx.models.sync_smartapp_event import ( BotAPISyncSmartAppEventErrorResponse, @@ -57,128 +50,18 @@ def bot_factory( return Bot(collectors=[bot_collector or collector], bot_accounts=bot_accounts) -# - FastAPI integration - -def get_bot(request: Request) -> Bot: - assert isinstance(request.app.state.bot, Bot) - - return request.app.state.bot - - -bot_dependency = Depends(get_bot) - -router = APIRouter() - - -@router.post("/command") -async def command_handler( - request: Request, - bot: Bot = bot_dependency, -) -> JSONResponse: - try: - bot.async_execute_raw_bot_command(await request.json(), verify_request=False) - except ValueError: - error_label = "Bot command validation error" - logger.exception(error_label) - - return JSONResponse( - build_bot_disabled_response(error_label), - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - ) - except UnknownBotAccountError as exc: - error_label = f"No credentials for bot {exc.bot_id}" - logger.warning(error_label) - - return JSONResponse( - build_bot_disabled_response(error_label), - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - ) - - return JSONResponse( - build_command_accepted_response(), - status_code=HTTPStatus.ACCEPTED, - ) - - -@router.post("/smartapps/request") -async def sync_smartapp_event_handler( - request: Request, - bot: Bot = bot_dependency, -) -> JSONResponse: - try: - response = await bot.sync_execute_raw_smartapp_event( - await request.json(), - verify_request=False, - ) - except ValueError: - error_label = "Bot command validation error" - logger.exception(error_label) - - return JSONResponse( - build_bot_disabled_response(error_label), - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - ) - except UnknownBotAccountError as exc: - error_label = f"No credentials for bot {exc.bot_id}" - logger.warning(error_label) - - return JSONResponse( - build_bot_disabled_response(error_label), - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - ) - - return JSONResponse(response.jsonable_dict(), status_code=HTTPStatus.OK) - - -@router.get("/status") -async def status_handler(request: Request, bot: Bot = bot_dependency) -> JSONResponse: - status = await bot.raw_get_status(dict(request.query_params), verify_request=False) - return JSONResponse(status) - - -@router.get("/status__unverified_request") -async def status_handler__unverified_request( - request: Request, - bot: Bot = bot_dependency, -) -> JSONResponse: - try: - status = await bot.raw_get_status( - dict(request.query_params), - request_headers=request.headers, - ) - except UnverifiedRequestError as exc: - return JSONResponse( - content=build_unverified_request_response( - status_message=exc.args[0], - ), - status_code=HTTPStatus.UNAUTHORIZED, - ) - return JSONResponse(status) - - -@router.post("/notification/callback") -async def callback_handler( - request: Request, - bot: Bot = bot_dependency, -) -> JSONResponse: - await bot.set_raw_botx_method_result(await request.json(), verify_request=False) - return JSONResponse( - build_command_accepted_response(), - status_code=HTTPStatus.ACCEPTED, +def fastapi_factory(bot: Bot, *, verify_request: bool = False) -> FastAPI: + return create_fastapi_bot_app( + bot=bot, + config=FastAPIBotAppConfig( + verify_request=verify_request, + verify_callback_request=False, + smartapp_request_path="/smartapps/request", + healthcheck_prefix=None, + ), ) -def fastapi_factory(bot: Bot) -> FastAPI: - application = FastAPI() - application.state.bot = bot - - application.add_event_handler("startup", bot.startup) - application.add_event_handler("shutdown", bot.shutdown) - - application.include_router(router) - - return application - - # https://www.uvicorn.org/#application-factories def asgi_factory() -> FastAPI: bot_accounts = [ @@ -402,9 +285,9 @@ def test__web_app__unverified_request_response( bot: Bot, ) -> None: # - Act - - with TestClient(fastapi_factory(bot)) as test_client: + with TestClient(fastapi_factory(bot, verify_request=True)) as test_client: response = test_client.get( - "/status__unverified_request", + "/status", params={}, ) diff --git a/tests/test_fastapi_adapter.py b/tests/test_fastapi_adapter.py new file mode 100644 index 00000000..34e64760 --- /dev/null +++ b/tests/test_fastapi_adapter.py @@ -0,0 +1,310 @@ +import importlib +import json +from types import SimpleNamespace +from typing import Any +from uuid import UUID + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from pybotx import ( + Bot, + BotAccountWithSecret, + FastAPIBotAppConfig, + HandlerCollector, + create_fastapi_bot_app, + setup_fastapi_bot, +) +from pybotx.bot.exceptions import UnknownBotAccountError, UnverifiedRequestError +from pybotx.integrations import fastapi as fastapi_adapter + +prometheus_client = pytest.importorskip("prometheus_client") +CollectorRegistry = prometheus_client.CollectorRegistry + + +def test__create_fastapi_bot_app__wires_metrics_and_health_routes( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + registry = CollectorRegistry() + + app = create_fastapi_bot_app( + bot=bot, + config=FastAPIBotAppConfig( + verify_request=False, + metrics_path="/metrics", + ), + metrics_registry=registry, + ) + + with TestClient(app) as test_client: + health_response = test_client.get("/health/") + readiness_response = test_client.get("/health/ready") + metrics_response = test_client.get("/metrics") + + assert health_response.status_code == 200 + assert readiness_response.status_code == 200 + assert metrics_response.status_code == 200 + assert metrics_response.headers["content-type"].startswith("text/plain") + + +def test__create_fastapi_bot_app__stores_extra_app_state( + bot_account: BotAccountWithSecret, +) -> None: + container = SimpleNamespace(name="container") + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + app = create_fastapi_bot_app( + bot=bot, + config=FastAPIBotAppConfig(verify_request=False), + app_state={"container": container}, + ) + + assert app.state.bot is bot + assert app.state.container is container + assert app.state.healthcheck is not None + + +def test__fastapi_bot_app_config__duplicate_path_rejected() -> None: + with pytest.raises(ValueError, match="Duplicate FastAPI route path"): + FastAPIBotAppConfig( + command_path="/command", + status_path="/command", + ) + + +def test__fastapi_bot_app_config__relative_path_rejected() -> None: + with pytest.raises(ValueError, match="should start"): + FastAPIBotAppConfig(command_path="command") + + +def test__create_fastapi_bot_app__metrics_registry_required( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + with pytest.raises(ValueError, match="metrics_registry"): + create_fastapi_bot_app( + bot=bot, + config=FastAPIBotAppConfig( + verify_request=False, + metrics_path="/metrics", + ), + ) + + +def test__create_fastapi_bot_app__reserved_app_state_keys_rejected( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + + with pytest.raises(ValueError, match="reserved state keys"): + create_fastapi_bot_app( + bot=bot, + config=FastAPIBotAppConfig(verify_request=False), + app_state={"bot": object()}, + ) + + +def test__setup_fastapi_bot__configures_existing_app( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + ) + app = FastAPI() + + setup_fastapi_bot( + app, + bot=bot, + config=FastAPIBotAppConfig(verify_request=False, healthcheck_prefix=None), + ) + + with TestClient(app) as test_client: + response = test_client.get( + "/status", + params={ + "bot_id": str(bot_account.id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + }, + ) + + assert response.status_code == 200 + + +def test__setup_fastapi_bot__rejects_healthcheck_when_disabled( + bot_account: BotAccountWithSecret, +) -> None: + bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + with pytest.raises(ValueError, match="healthcheck"): + setup_fastapi_bot( + FastAPI(), + bot=bot, + config=FastAPIBotAppConfig(healthcheck_prefix=None), + healthcheck=object(), # type: ignore[arg-type] + ) + + +class _Request: + headers = {"authorization": "Bearer token"} + query_params = {"bot_id": "value"} + + async def json(self) -> dict[str, str]: + return {"payload": "value"} + + +class _ResponsePayload: + def jsonable_dict(self) -> dict[str, str]: + return {"result": "ok"} + + +class _RouteBot: + def __init__(self) -> None: + self.command_error: Exception | None = None + self.status_error: Exception | None = None + self.callback_error: Exception | None = None + self.smartapp_error: Exception | None = None + + async def startup(self) -> None: + return None + + async def shutdown(self) -> None: + return None + + def async_execute_raw_bot_command(self, *_args: Any, **_kwargs: Any) -> None: + if self.command_error is not None: + raise self.command_error + + async def raw_get_status(self, *_args: Any, **_kwargs: Any) -> dict[str, str]: + if self.status_error is not None: + raise self.status_error + return {"status": "ok"} + + async def set_raw_botx_method_result(self, *_args: Any, **_kwargs: Any) -> None: + if self.callback_error is not None: + raise self.callback_error + + async def sync_execute_raw_smartapp_event( + self, + *_args: Any, + **_kwargs: Any, + ) -> _ResponsePayload: + if self.smartapp_error is not None: + raise self.smartapp_error + return _ResponsePayload() + + +def _endpoint(app: FastAPI, path: str) -> Any: + return next( + getattr(route, "endpoint") + for route in app.routes + if getattr(route, "path", None) == path + ) + + +@pytest.mark.parametrize( + ("route", "method", "error", "status_code"), + [ + ("/command", "command_error", ValueError(), 503), + ("/command", "command_error", UnknownBotAccountError(UUID(int=1)), 503), + ("/command", "command_error", UnverifiedRequestError("bad token"), 401), + ("/status", "status_error", ValueError(), 503), + ("/status", "status_error", UnknownBotAccountError(UUID(int=2)), 503), + ("/status", "status_error", UnverifiedRequestError("bad token"), 401), + ("/callback", "callback_error", UnverifiedRequestError("bad token"), 401), + ("/smartapp", "smartapp_error", ValueError(), 503), + ("/smartapp", "smartapp_error", UnknownBotAccountError(UUID(int=3)), 503), + ("/smartapp", "smartapp_error", UnverifiedRequestError("bad token"), 401), + ], +) +async def test__fastapi_handlers__map_domain_errors( + route: str, + method: str, + error: Exception, + status_code: int, +) -> None: + bot = _RouteBot() + setattr(bot, method, error) + app = FastAPI() + setup_fastapi_bot( + app, + bot=bot, # type: ignore[arg-type] + config=FastAPIBotAppConfig( + callback_path="/callback", + smartapp_request_path="/smartapp", + healthcheck_prefix=None, + trusted_issuers=frozenset({"trusted.example"}), + ), + ) + + response = await _endpoint(app, route)(_Request()) + + assert response.status_code == status_code + + +async def test__fastapi_smartapp_handler__returns_domain_response() -> None: + bot = _RouteBot() + app = FastAPI() + setup_fastapi_bot( + app, + bot=bot, # type: ignore[arg-type] + config=FastAPIBotAppConfig( + smartapp_request_path="/smartapp", + healthcheck_prefix=None, + ), + ) + + response = await _endpoint(app, "/smartapp")(_Request()) + + assert response.status_code == 200 + assert json.loads(response.body) == {"result": "ok"} + + +@pytest.mark.parametrize("missing_module", ["fastapi", "fastapi.responses"]) +def test__import_fastapi_runtime__reports_missing_dependency( + monkeypatch: pytest.MonkeyPatch, + missing_module: str, +) -> None: + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == missing_module: + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + with pytest.raises(RuntimeError, match="requires `fastapi`"): + fastapi_adapter._import_fastapi_runtime() + + +def test__build_metrics_runtime__reports_missing_dependency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "prometheus_client": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + with pytest.raises(RuntimeError, match="prometheus-client"): + fastapi_adapter._build_metrics_runtime() diff --git a/tests/test_handler_collector.py b/tests/test_handler_collector.py index 88c54e5c..88f997e5 100644 --- a/tests/test_handler_collector.py +++ b/tests/test_handler_collector.py @@ -1,5 +1,7 @@ +import asyncio from copy import deepcopy -from typing import Any +from types import SimpleNamespace +from typing import Any, cast from collections.abc import Callable from unittest.mock import Mock @@ -8,13 +10,23 @@ from pybotx import ( Bot, BotAccountWithSecret, + BotCommandOverloadAction, + BotCommandProcessingConfig, + BotCommandRejectedError, ChatCreatedEvent, + DropOldestBotCommandOverloadStrategy, HandlerCollector, IncomingMessage, + IngressCommandMetadata, + IngressCommandResult, + RejectNewBotCommandOverloadStrategy, SmartAppEvent, SyncSmartAppEventHandlerNotFoundError, lifespan_wrapper, ) +from pybotx.models.commands import BotCommand +from pybotx.bot.contextvars import bot_id_var, chat_id_var, request_id_var, trace_id_var +from pybotx.bot.handler_collector import _QueuedBotCommand pytestmark = [ pytest.mark.mock_authorization, @@ -22,6 +34,95 @@ ] +@pytest.mark.asyncio +async def test__handler_collector__internal_queue_edge_paths( + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + message = incoming_message_factory(body="plain text") + loop = asyncio.get_running_loop() + collector = HandlerCollector( + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=1, + overload_strategy=DropOldestBotCommandOverloadStrategy(), + ), + ) + completion = loop.create_future() + queued = _QueuedBotCommand( + bot=Mock(), + bot_command=message, + completion=completion, + request_id=None, + trace_id=None, + ingress_metadata=IngressCommandMetadata("incoming_message", "__default__"), + ) + + collector._is_shutting_down = True + collector._enqueue_or_reject(queued) + with pytest.raises(BotCommandRejectedError, match="bot_shutting_down"): + await completion + collector._reject_queued_command(queued, reason="already_done") + + await collector._command_queue.put(collector._STOP_QUEUE_ITEM) + assert collector._drop_oldest_queued_command() is None + + collector._is_shutting_down = False + collector._workers.add(Mock()) # Prevent worker startup while exercising overflow. + await collector._command_queue.put(collector._STOP_QUEUE_ITEM) + rejected_completion = loop.create_future() + rejected = _QueuedBotCommand( + bot=Mock(), + bot_command=message, + completion=rejected_completion, + request_id=None, + trace_id=None, + ingress_metadata=queued.ingress_metadata, + ) + collector._enqueue_or_reject(rejected) + with pytest.raises(BotCommandRejectedError, match="queue_overflow_reject_new"): + await rejected_completion + + cancelled = asyncio.create_task(asyncio.sleep(0)) + cancelled.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled + collector._log_unhandled_task_exception(cancelled) + + assert collector._extract_request_id_from_command(cast(BotCommand, SimpleNamespace())) is None + assert collector._set_ingress_contextvars(queued) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("should_fail", [False, True]) +async def test__command_worker__does_not_resolve_completed_future( + incoming_message_factory: Callable[..., IncomingMessage], + should_fail: bool, +) -> None: + collector = HandlerCollector() + + @collector.default_message_handler + async def handler(_message: IncomingMessage, _bot: Bot) -> None: + if should_fail: + raise RuntimeError("handler failed") + + completion = asyncio.get_running_loop().create_future() + completion.set_result(None) + queued = _QueuedBotCommand( + bot=Mock(), + bot_command=incoming_message_factory(body="plain text"), + completion=completion, + request_id=None, + trace_id=None, + ingress_metadata=IngressCommandMetadata("incoming_message", "__default__"), + ) + await collector._command_queue.put(queued) + await collector._command_queue.put(collector._STOP_QUEUE_ITEM) + + await collector._command_worker() + + assert completion.result() is None + + def test__handler_collector__command_with_space_error_raised() -> None: # - Arrange - collector = HandlerCollector() @@ -517,6 +618,365 @@ async def handler(message: IncomingMessage, bot: Bot) -> None: correct_handler_trigger.assert_called_once() +@pytest.mark.asyncio +async def test__handler_collector__limits_incoming_commands_parallelism( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + started = 0 + running = 0 + max_running = 0 + started_event = asyncio.Event() + release_event = asyncio.Event() + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal started, running, max_running + running += 1 + started += 1 + max_running = max(max_running, running) + if started >= 2: + started_event.set() + await release_event.wait() + running -= 1 + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=2, + max_queue_size=10, + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + tasks = [ + bot.async_execute_bot_command( + incoming_message_factory(body=f"/command {index}"), + ) + for index in range(5) + ] + await asyncio.wait_for(started_event.wait(), timeout=1.0) + await asyncio.sleep(0.05) + release_event.set() + await asyncio.gather(*tasks) + + # - Assert - + assert max_running == 2 + + +@pytest.mark.asyncio +async def test__handler_collector__reject_new_policy_when_queue_full( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + handled_messages: list[str] = [] + first_started_event = asyncio.Event() + release_event = asyncio.Event() + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + handled_messages.append(message.body) + if message.body.endswith("1"): + first_started_event.set() + await release_event.wait() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=1, + overload_strategy=RejectNewBotCommandOverloadStrategy(), + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task_1 = bot.async_execute_bot_command(incoming_message_factory(body="/command 1")) + await asyncio.wait_for(first_started_event.wait(), timeout=1.0) + task_2 = bot.async_execute_bot_command(incoming_message_factory(body="/command 2")) + task_3 = bot.async_execute_bot_command(incoming_message_factory(body="/command 3")) + + with pytest.raises(BotCommandRejectedError, match="queue_overflow_reject_new"): + await task_3 + + release_event.set() + await asyncio.gather(task_1, task_2) + + # - Assert - + assert handled_messages == ["/command 1", "/command 2"] + + +@pytest.mark.asyncio +async def test__handler_collector__supports_custom_overload_strategy( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + class _CustomStrategy: + def __init__(self) -> None: + self.invocations = 0 + + def on_queue_overflow( + self, + *, + queue_size: int, + queue_max_size: int, + ) -> BotCommandOverloadAction: + self.invocations += 1 + assert queue_size == queue_max_size == 1 + return BotCommandOverloadAction.REJECT_NEW + + strategy = _CustomStrategy() + first_started_event = asyncio.Event() + release_event = asyncio.Event() + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + if message.body.endswith("1"): + first_started_event.set() + await release_event.wait() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=1, + overload_strategy=strategy, + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task_1 = bot.async_execute_bot_command(incoming_message_factory(body="/command 1")) + await asyncio.wait_for(first_started_event.wait(), timeout=1.0) + bot.async_execute_bot_command(incoming_message_factory(body="/command 2")) + task_3 = bot.async_execute_bot_command(incoming_message_factory(body="/command 3")) + + with pytest.raises(BotCommandRejectedError, match="queue_overflow_reject_new"): + await task_3 + release_event.set() + await task_1 + + # - Assert - + assert strategy.invocations == 1 + + +@pytest.mark.asyncio +async def test__handler_collector__drop_oldest_policy_when_queue_full( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + handled_messages: list[str] = [] + first_started_event = asyncio.Event() + release_event = asyncio.Event() + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + handled_messages.append(message.body) + if message.body.endswith("1"): + first_started_event.set() + await release_event.wait() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=1, + overload_strategy=DropOldestBotCommandOverloadStrategy(), + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task_1 = bot.async_execute_bot_command(incoming_message_factory(body="/command 1")) + await asyncio.wait_for(first_started_event.wait(), timeout=1.0) + task_2 = bot.async_execute_bot_command(incoming_message_factory(body="/command 2")) + task_3 = bot.async_execute_bot_command(incoming_message_factory(body="/command 3")) + + with pytest.raises(BotCommandRejectedError, match="queue_overflow_drop_oldest"): + await task_2 + + release_event.set() + await asyncio.gather(task_1, task_3) + + # - Assert - + assert handled_messages == ["/command 1", "/command 3"] + + +@pytest.mark.asyncio +async def test__handler_collector__reports_ingress_metrics( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + class _IngressCollector: + def __init__(self) -> None: + self.queue_depth_updates: list[int] = [] + self.rejections: list[tuple[IngressCommandMetadata, str, int]] = [] + self.finished: list[tuple[IngressCommandMetadata, IngressCommandResult, int]] = [] + + def on_queue_depth(self, queue_depth: int) -> None: + self.queue_depth_updates.append(queue_depth) + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + self.rejections.append((metadata, reason, queue_depth)) + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + self.finished.append((metadata, result, queue_depth)) + + ingress_collector = _IngressCollector() + release_event = asyncio.Event() + started_event = asyncio.Event() + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + if message.body.endswith("1"): + started_event.set() + await release_event.wait() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + ingress_metrics_collector=ingress_collector, + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=1, + overload_strategy=RejectNewBotCommandOverloadStrategy(), + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task_1 = bot.async_execute_bot_command(incoming_message_factory(body="/command 1")) + await asyncio.wait_for(started_event.wait(), timeout=1.0) + task_2 = bot.async_execute_bot_command(incoming_message_factory(body="/command 2")) + task_3 = bot.async_execute_bot_command(incoming_message_factory(body="/command 3")) + + with pytest.raises(BotCommandRejectedError, match="queue_overflow_reject_new"): + await task_3 + + release_event.set() + await asyncio.gather(task_1, task_2) + + # - Assert - + assert ingress_collector.rejections + rejected_metadata, rejected_reason, _ = ingress_collector.rejections[0] + assert rejected_metadata.command_kind == "incoming_message" + assert rejected_metadata.command_name == "/command" + assert rejected_reason == "queue_overflow_reject_new" + assert len(ingress_collector.finished) == 2 + assert ingress_collector.finished[0][1].duration_ms >= 0 + assert ingress_collector.finished[1][1].duration_ms >= 0 + assert ingress_collector.queue_depth_updates[-1] == 0 + + +@pytest.mark.asyncio +async def test__handler_collector__propagates_request_and_trace_ids_to_worker( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = api_incoming_message_factory(body="/command hi") + collector = HandlerCollector() + seen_request_id: str | None = None + seen_trace_id: str | None = None + seen_bot_id: str | None = None + seen_chat_id: str | None = None + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal seen_request_id, seen_trace_id, seen_bot_id, seen_chat_id + seen_request_id = request_id_var.get() + seen_trace_id = trace_id_var.get() + seen_bot_id = str(bot_id_var.get()) + seen_chat_id = str(chat_id_var.get()) + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + command_processing_config=BotCommandProcessingConfig( + max_concurrency=1, + max_queue_size=10, + ), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command( + payload, + verify_request=False, + request_headers={ + "X-Request-Id": "request-id-123", + "traceparent": ( + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-" + "bbbbbbbbbbbbbbbb-01" + ), + }, + ) + + # - Assert - + assert seen_request_id == "request-id-123" + assert seen_trace_id == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + assert seen_bot_id == payload["bot_id"] + assert seen_chat_id == payload["from"]["group_chat_id"] + + +@pytest.mark.asyncio +async def test__handler_collector__falls_back_to_sync_id_for_request_and_trace( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = api_incoming_message_factory(body="/command hi") + collector = HandlerCollector() + seen_request_id: str | None = None + seen_trace_id: str | None = None + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal seen_request_id, seen_trace_id + seen_request_id = request_id_var.get() + seen_trace_id = trace_id_var.get() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload, verify_request=False) + + # - Assert - + assert seen_request_id == payload["sync_id"] + assert seen_trace_id == payload["sync_id"] + + @pytest.mark.asyncio async def test__handler_collector__handle_sync_smartapp_event__handler_not_found( bot_account: BotAccountWithSecret, @@ -535,6 +995,61 @@ async def test__handler_collector__handle_sync_smartapp_event__handler_not_found ) +@pytest.mark.asyncio +async def test__handler_collector__sync_smartapp_event_reports_error_to_ingress_metrics( + bot_account: BotAccountWithSecret, + smartapp_event: SmartAppEvent, +) -> None: + class _IngressCollector: + def __init__(self) -> None: + self.finished: list[tuple[IngressCommandMetadata, IngressCommandResult]] = [] + + def on_queue_depth(self, queue_depth: int) -> None: + pass + + def on_command_rejected( + self, + metadata: IngressCommandMetadata, + *, + reason: str, + queue_depth: int, + ) -> None: + pass + + def on_command_finished( + self, + metadata: IngressCommandMetadata, + result: IngressCommandResult, + *, + queue_depth: int, + ) -> None: + self.finished.append((metadata, result)) + + ingress_collector = _IngressCollector() + collector = HandlerCollector() + + @collector.sync_smartapp_event + async def sync_handler(event: SmartAppEvent, bot: Bot) -> Any: + raise RuntimeError("boom") + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + ingress_metrics_collector=ingress_collector, + ) + + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(RuntimeError, match="boom"): + await bot.sync_execute_smartapp_event(smartapp_event) + + assert ingress_collector.finished + metadata, result = ingress_collector.finished[0] + assert metadata.command_kind == "sync_smartapp_event" + assert metadata.command_name == "SmartAppEvent" + assert result.duration_ms >= 0 + assert isinstance(result.error, RuntimeError) + + @pytest.mark.asyncio async def test__handler_collector__sync_smartapp_event__include__handler_already_registered( collector_with_sync_smartapp_event_handler: HandlerCollector, diff --git a/tests/test_healthcheck.py b/tests/test_healthcheck.py new file mode 100644 index 00000000..64364205 --- /dev/null +++ b/tests/test_healthcheck.py @@ -0,0 +1,244 @@ +import asyncio +from datetime import datetime +from http import HTTPStatus +from typing import Any, Literal, cast + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from pybotx import ( + HealthcheckService, + ReadinessCheck, + ReadinessCheckResult, + build_healthcheck_router, + setup_healthcheck, +) + + +def _assert_utc_timestamp(timestamp: str) -> None: + parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + assert parsed_timestamp.tzinfo is not None + + +def test__healthcheck_routes__not_connected_by_default() -> None: + app = FastAPI() + + with TestClient(app) as test_client: + response = test_client.get("/health/") + + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test__setup_healthcheck__liveness_success() -> None: + app = FastAPI() + setup_healthcheck(app) + + with TestClient(app) as test_client: + response = test_client.get("/health/") + + assert response.status_code == HTTPStatus.OK + assert response.json()["status"] == "ok" + _assert_utc_timestamp(response.json()["timestamp"]) + + +def test__healthcheck_liveness__fatal_state_and_reset() -> None: + app = FastAPI() + healthcheck = setup_healthcheck(app) + + with TestClient(app) as test_client: + healthcheck.mark_fatal() + response_with_fatal = test_client.get("/health/") + healthcheck.clear_fatal() + response_after_reset = test_client.get("/health/") + + assert response_with_fatal.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert response_with_fatal.json()["status"] == "fail" + assert response_after_reset.status_code == HTTPStatus.OK + assert response_after_reset.json()["status"] == "ok" + + +def test__healthcheck_readiness__initialization_failed() -> None: + app = FastAPI() + healthcheck = setup_healthcheck(app) + + with TestClient(app) as test_client: + healthcheck.mark_uninitialized() + response = test_client.get("/health/ready") + + response_payload = response.json() + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert response_payload == { + "status": "fail", + "time": response_payload["time"], + "checks": { + "initialization": { + "status": "fail", + "reason": "application isn't initialized", + }, + }, + } + _assert_utc_timestamp(response_payload["time"]) + + +def test__healthcheck_readiness__critical_and_non_critical_statuses() -> None: + async def critical_check() -> bool: + return False + + async def non_critical_check() -> Literal["degraded"]: + return "degraded" + + app = FastAPI() + setup_healthcheck( + app, + healthcheck=HealthcheckService( + readiness_checks=[ + ReadinessCheck(name="db", check=critical_check, critical=True), + ReadinessCheck( + name="ext_api", + check=non_critical_check, + critical=False, + timeout_seconds=None, + ), + ], + ), + ) + + with TestClient(app) as test_client: + response = test_client.get("/health/ready") + + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert response.json()["status"] == "fail" + assert response.json()["checks"]["db"]["status"] == "fail" + assert response.json()["checks"]["ext_api"]["status"] == "degraded" + + +def test__healthcheck_readiness__non_critical_fail_is_degraded() -> None: + async def redis_check() -> ReadinessCheckResult: + return ReadinessCheckResult(status="fail", reason="timeout", latency_ms=200) + + app = FastAPI() + setup_healthcheck( + app, + healthcheck=HealthcheckService( + readiness_checks=[ + ReadinessCheck(name="redis", check=redis_check, critical=False), + ], + ), + ) + + with TestClient(app) as test_client: + response = test_client.get("/health/ready") + + response_payload = response.json() + assert response.status_code == HTTPStatus.OK + assert response_payload == { + "status": "degraded", + "time": response_payload["time"], + "checks": { + "initialization": {"status": "ok"}, + "redis": {"status": "fail", "reason": "timeout", "latency_ms": 200}, + }, + } + + +def test__healthcheck_readiness__timeout_and_exception() -> None: + async def timeout_check() -> bool: + await asyncio.sleep(0.02) + return True + + async def broken_check() -> Any: + return 1 + + app = FastAPI() + setup_healthcheck( + app, + healthcheck=HealthcheckService( + readiness_checks=[ + ReadinessCheck( + name="timeout_check", + check=timeout_check, + critical=False, + timeout_seconds=0.001, + ), + ReadinessCheck(name="broken_check", check=cast(Any, broken_check)), + ], + ), + ) + + with TestClient(app) as test_client: + response = test_client.get("/health/ready") + + response_payload = response.json() + timeout_result = response_payload["checks"]["timeout_check"] + broken_result = response_payload["checks"]["broken_check"] + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert response_payload["status"] == "fail" + assert timeout_result["status"] == "fail" + assert timeout_result["reason"] == "timeout" + assert isinstance(timeout_result["latency_ms"], int) + assert broken_result["status"] == "fail" + assert "TypeError" in broken_result["reason"] + + +def test__healthcheck_readiness__without_initialization_check() -> None: + app = FastAPI() + healthcheck = HealthcheckService( + readiness_checks=[ + ReadinessCheck(name="db", check=lambda: True), + ReadinessCheck(name="cache", check=lambda: ReadinessCheckResult(status="ok")), + ], + include_initialization_check=False, + ) + healthcheck.mark_fatal("fatal app state") + setup_healthcheck(app, healthcheck=healthcheck) + + with TestClient(app) as test_client: + response = test_client.get("/health/ready") + + response_payload = response.json() + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert response_payload["status"] == "fail" + assert response_payload["checks"]["application"] == { + "status": "fail", + "reason": "fatal app state", + } + assert response_payload["checks"]["db"]["status"] == "ok" + assert response_payload["checks"]["cache"]["status"] == "ok" + + +def test__build_healthcheck_router__works_with_manual_include() -> None: + app = FastAPI() + healthcheck = HealthcheckService() + app.include_router( + build_healthcheck_router(healthcheck=healthcheck), + prefix="/health", + ) + + with TestClient(app) as test_client: + response = test_client.get("/health/ready") + + assert response.status_code == HTTPStatus.OK + assert response.json()["checks"]["initialization"]["status"] == "ok" + + +def test__healthcheck_validation() -> None: + with pytest.raises(ValueError, match="prefix"): + setup_healthcheck(FastAPI(), prefix="health") + + with pytest.raises(ValueError, match="name should not be empty"): + ReadinessCheck(name="", check=lambda: True) + + with pytest.raises(ValueError, match="timeout should be greater than 0"): + ReadinessCheck(name="db", check=lambda: True, timeout_seconds=0) + + with pytest.raises(ValueError, match="status should be one of"): + ReadinessCheckResult(status="bad") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="latency should be greater or equal to 0"): + ReadinessCheckResult(status="ok", latency_ms=-1) + + healthcheck = HealthcheckService() + healthcheck.add_readiness_check(ReadinessCheck(name="db", check=lambda: True)) + with pytest.raises(ValueError, match="already exists"): + healthcheck.add_readiness_check(ReadinessCheck(name="db", check=lambda: True)) diff --git a/tests/test_ingress_observability.py b/tests/test_ingress_observability.py new file mode 100644 index 00000000..0d1efb2e --- /dev/null +++ b/tests/test_ingress_observability.py @@ -0,0 +1,178 @@ +import importlib + +import pytest + +from pybotx import ( + InMemoryIngressMetricsCollector, + IngressCommandMetadata, + IngressCommandResult, + NoopIngressMetricsCollector, + PrometheusIngressMetricsCollector, +) + + +def test__noop_ingress_metrics_collector__does_not_fail() -> None: + collector = NoopIngressMetricsCollector() + metadata = IngressCommandMetadata( + command_kind="incoming_message", + command_name="/demo", + ) + + collector.on_queue_depth(1) + collector.on_command_rejected( + metadata, + reason="queue_overflow_reject_new", + queue_depth=1, + ) + collector.on_command_finished( + metadata, + IngressCommandResult(duration_ms=12), + queue_depth=0, + ) + + +def test__in_memory_ingress_metrics_collector__collects_metrics() -> None: + # - Arrange - + collector = InMemoryIngressMetricsCollector() + metadata = IngressCommandMetadata( + command_kind="incoming_message", + command_name="/demo", + ) + error = RuntimeError("boom") + + # - Act - + collector.on_queue_depth(-1) + collector.on_command_rejected( + metadata, + reason="queue_overflow_reject_new", + queue_depth=5, + ) + collector.on_command_finished( + metadata, + IngressCommandResult(duration_ms=10), + queue_depth=3, + ) + collector.on_command_finished( + metadata, + IngressCommandResult(duration_ms=15, error=error), + queue_depth=0, + ) + snapshot = collector.snapshot() + + # - Assert - + assert snapshot.queue_depth == 0 + assert snapshot.commands_total == 2 + assert snapshot.errors_total == 1 + assert snapshot.rejected_total == 1 + assert snapshot.latency_sum_ms == 25 + assert snapshot.command_outcomes[("incoming_message", "/demo", "ok")] == 1 + assert snapshot.command_outcomes[("incoming_message", "/demo", "error")] == 1 + assert snapshot.command_errors[("incoming_message", "/demo", "RuntimeError")] == 1 + assert snapshot.command_rejections[ + ("incoming_message", "/demo", "queue_overflow_reject_new") + ] == 1 + + +def test__prometheus_ingress_metrics_collector__collects_metrics() -> None: + prometheus_client = pytest.importorskip("prometheus_client") + registry = prometheus_client.CollectorRegistry() + collector = PrometheusIngressMetricsCollector(registry=registry) + metadata = IngressCommandMetadata( + command_kind="system_event", + command_name="CTSLoginEvent", + ) + + collector.on_queue_depth(2) + collector.on_command_rejected( + metadata, + reason="queue_overflow_reject_new:reason", + queue_depth=2, + ) + collector.on_command_finished( + metadata, + IngressCommandResult(duration_ms=50), + queue_depth=1, + ) + collector.on_command_finished( + metadata, + IngressCommandResult(duration_ms=30, error=ValueError("boom")), + queue_depth=0, + ) + + assert registry.get_sample_value("pybotx_ingress_queue_depth") == 0.0 + assert registry.get_sample_value( + "pybotx_ingress_rejected_total", + { + "command_kind": "system_event", + "command_name": "CTSLoginEvent", + "reason": "queue_overflow_reject_new", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_ingress_commands_total", + { + "command_kind": "system_event", + "command_name": "CTSLoginEvent", + "outcome": "ok", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_ingress_commands_total", + { + "command_kind": "system_event", + "command_name": "CTSLoginEvent", + "outcome": "error", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_ingress_errors_total", + { + "command_kind": "system_event", + "command_name": "CTSLoginEvent", + "error_type": "ValueError", + }, + ) == 1.0 + + +def test__prometheus_ingress_metrics_collector__supports_custom_reason_normalizer() -> None: + prometheus_client = pytest.importorskip("prometheus_client") + registry = prometheus_client.CollectorRegistry() + collector = PrometheusIngressMetricsCollector( + registry=registry, + reason_normalizer=lambda _: "custom_reason", + ) + metadata = IngressCommandMetadata( + command_kind="incoming_message", + command_name="/demo", + ) + + collector.on_command_rejected( + metadata, + reason="queue_overflow_reject_new:details", + queue_depth=1, + ) + + assert registry.get_sample_value( + "pybotx_ingress_rejected_total", + { + "command_kind": "incoming_message", + "command_name": "/demo", + "reason": "custom_reason", + }, + ) == 1.0 + + +def test__prometheus_ingress_metrics_collector__requires_prometheus_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "prometheus_client": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + with pytest.raises(RuntimeError, match="prometheus-client"): + PrometheusIngressMetricsCollector() diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 00000000..b8ded1a0 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,70 @@ +from contextvars import Token +from typing import Any, cast +from uuid import UUID + +import pytest + +from pybotx.bot.contextvars import bot_id_var, chat_id_var, request_id_var, trace_id_var +from pybotx.logger import _inject_correlation_fields, _is_env_enabled, logger + + +def test__logger__injects_correlation_fields_from_contextvars() -> None: + bot_id_token: Token[UUID] = bot_id_var.set(UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3")) + chat_id_token: Token[UUID] = chat_id_var.set(UUID("d4d3d774-1f90-4b53-9b92-7f3867dbb2f8")) + request_id_token: Token[str] = request_id_var.set("request-id") + trace_id_token: Token[str] = trace_id_var.set("trace-id") + record: dict[str, dict[str, str | None]] = {"extra": {}} + + try: + _inject_correlation_fields(record) + finally: + trace_id_var.reset(trace_id_token) + request_id_var.reset(request_id_token) + chat_id_var.reset(chat_id_token) + bot_id_var.reset(bot_id_token) + + assert record["extra"]["request_id"] == "request-id" + assert record["extra"]["trace_id"] == "trace-id" + assert record["extra"]["bot_id"] == "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" + assert record["extra"]["chat_id"] == "d4d3d774-1f90-4b53-9b92-7f3867dbb2f8" + + +def test__logger__inject_does_not_override_existing_fields() -> None: + record = { + "extra": { + "trace_id": "existing-trace", + "request_id": "existing-request", + "bot_id": "existing-bot", + "chat_id": "existing-chat", + }, + } + + _inject_correlation_fields(record) + + assert record["extra"] == { + "trace_id": "existing-trace", + "request_id": "existing-request", + "bot_id": "existing-bot", + "chat_id": "existing-chat", + } + + +def test__logger__structured_json_by_default() -> None: + handler = next(iter(cast(Any, logger)._core.handlers.values())) + + assert getattr(handler, "_serialize", None) is True + + +def test__logger__is_env_enabled_uses_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PYBOTX_LOG_STRUCTURED", raising=False) + assert _is_env_enabled("PYBOTX_LOG_STRUCTURED", default=True) is True + assert _is_env_enabled("PYBOTX_LOG_STRUCTURED", default=False) is False + + +def test__logger__is_env_enabled_parses_false_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYBOTX_LOG_STRUCTURED", "off") + assert _is_env_enabled("PYBOTX_LOG_STRUCTURED", default=True) is False diff --git a/tests/test_logs.py b/tests/test_logs.py index d6e7f64c..5c8c24ea 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -1,4 +1,5 @@ from http import HTTPStatus +import importlib from typing import Any, cast from collections.abc import Callable from uuid import UUID @@ -16,6 +17,7 @@ lifespan_wrapper, ) from pybotx.models.attachments import AttachmentDocument, OutgoingAttachment +logger_module = importlib.import_module("pybotx.logger") pytestmark = [ pytest.mark.asyncio, @@ -24,6 +26,14 @@ ] +async def test__setup_logger__can_skip_default_sink_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYBOTX_CONFIGURE_LOGGER", "false") + + assert logger_module.setup_logger() is not None + + async def test__attachment__trimmed_in_incoming_message( bot_account: BotAccountWithSecret, api_incoming_message_factory: Callable[..., dict[str, Any]], diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 00000000..3908a870 --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,245 @@ +import importlib +from dataclasses import dataclass +from typing import Any, cast + +import pytest + +from pybotx import ( + BotProductionPreset, + BotXOperation, + BotXRequestMetadata, + BotXRetryRequestPolicy, + BotXRetryStrategy, + build_production_bot_preset, + build_production_observability_preset, + build_production_retry_preset, +) + + +@dataclass +class _FakeStatus: + status_code: str + description: str | None = None + + +class _FakeStatusCode: + OK = "OK" + ERROR = "ERROR" + + +class _FakeSpanKind: + CLIENT = "CLIENT" + + +class _FakeSpan: + def __init__(self, name: str, kind: str | None = None) -> None: + self.name = name + self.kind = kind + + def set_attribute(self, _key: str, _value: object) -> None: + return None + + def add_event(self, _name: str, attributes: dict[str, object] | None = None) -> None: + return None + + def record_exception(self, _exc: Exception) -> None: + return None + + def set_status(self, _status: _FakeStatus) -> None: + return None + + def end(self) -> None: + return None + + +class _FakeTracer: + def start_span(self, name: str, kind: str | None = None) -> _FakeSpan: + return _FakeSpan(name, kind) + + +class _FakeTraceAPI: + Status = _FakeStatus + StatusCode = _FakeStatusCode + SpanKind = _FakeSpanKind + + def __init__(self) -> None: + self._tracer = _FakeTracer() + + def get_tracer(self, *_args: object, **_kwargs: object) -> _FakeTracer: + return self._tracer + + +def test__build_production_retry_preset__builds_retry_kwargs_and_safe_policy() -> None: + preset = build_production_retry_preset(max_attempts=5) + + kwargs = preset.as_bot_kwargs() + retry_request_policy = kwargs["retry_request_policy"] + assert hasattr(retry_request_policy, "should_retry") + assert kwargs["retry_policy"] == preset.retry_policy + assert preset.retry_policy.max_attempts == 5 + assert retry_request_policy.should_retry( + BotXRequestMetadata( + method="GET", + url="https://cts.example.com/api/v4/botx/chats/info", + ), + ) + assert retry_request_policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/users/search", + operation_name=BotXOperation.SEARCH_USER_BY_EMAILS, + ), + ) + assert not retry_request_policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/api/v4/botx/notifications/direct", + operation_name=BotXOperation.DIRECT_NOTIFICATION, + ), + ) + + +def test__build_production_retry_preset__supports_extra_safe_operations() -> None: + preset = build_production_retry_preset(extra_safe_operations={"CustomReadOnlyMethod"}) + + assert preset.retry_request_policy.should_retry( + BotXRequestMetadata( + method="POST", + url="https://cts.example.com/custom/read-only", + operation_name="CustomReadOnlyMethod", + ), + ) + + +def test__retry_and_production_presets__include_optional_components() -> None: + retry_strategy = object() + retry_preset = build_production_retry_preset( + retryable_status_codes={599}, + retry_strategy=cast(BotXRetryStrategy, retry_strategy), + ) + + assert retry_preset.retry_policy.retryable_status_codes == frozenset({599}) + assert retry_preset.as_bot_kwargs()["retry_strategy"] is retry_strategy + assert BotProductionPreset(retry=retry_preset).as_bot_kwargs() + assert BotProductionPreset().as_bot_kwargs() == {} + + custom_policy = type( + "CustomPolicy", + (), + {"should_retry": lambda self, metadata: False}, + )() + custom = build_production_retry_preset( + retry_request_policy=custom_policy, + ) + assert custom.retry_request_policy is custom_policy + + +def test__build_production_retry_preset__rejects_mixed_custom_request_policy_and_extras() -> None: + class _RetryAllPolicy(BotXRetryRequestPolicy): + def should_retry(self, metadata: Any) -> bool: + return True + + with pytest.raises(ValueError): + build_production_retry_preset( + retry_request_policy=_RetryAllPolicy(), + extra_safe_operations={"CustomReadOnlyMethod"}, + ) + + +def test__build_production_observability_preset__requires_at_least_one_backend() -> None: + with pytest.raises(ValueError): + build_production_observability_preset( + enable_prometheus_metrics=False, + enable_open_telemetry_tracing=False, + ) + + +def test__build_production_observability_preset__builds_prometheus_metrics_only() -> None: + prometheus_client = pytest.importorskip("prometheus_client") + registry = prometheus_client.CollectorRegistry() + + preset = build_production_observability_preset( + enable_open_telemetry_tracing=False, + registry=registry, + ) + + kwargs = preset.as_bot_kwargs() + assert "metrics_collector" in kwargs + assert "ingress_metrics_collector" in kwargs + assert "tracing_collector" not in kwargs + + +def test__build_production_observability_preset__builds_tracing_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + trace_api = _FakeTraceAPI() + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + preset = build_production_observability_preset( + enable_prometheus_metrics=False, + tracer_name="tests.pybotx", + ) + + kwargs = preset.as_bot_kwargs() + assert "metrics_collector" not in kwargs + assert "ingress_metrics_collector" not in kwargs + assert "tracing_collector" in kwargs + + +def test__build_production_bot_preset__combines_retry_and_observability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + prometheus_client = pytest.importorskip("prometheus_client") + trace_api = _FakeTraceAPI() + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + registry = prometheus_client.CollectorRegistry() + + preset = build_production_bot_preset( + retry_preset=build_production_retry_preset(), + observability_preset=build_production_observability_preset(registry=registry), + ) + + kwargs = preset.as_bot_kwargs() + assert "retry_policy" in kwargs + assert "retry_request_policy" in kwargs + assert "metrics_collector" in kwargs + assert "tracing_collector" in kwargs + assert "ingress_metrics_collector" in kwargs + + +def test__build_production_bot_preset__default_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("prometheus_client") + trace_api = _FakeTraceAPI() + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "opentelemetry.trace": + return trace_api + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + preset = build_production_bot_preset() + + kwargs = preset.as_bot_kwargs() + assert "retry_policy" in kwargs + assert "retry_request_policy" in kwargs + assert "metrics_collector" in kwargs + assert "tracing_collector" in kwargs + assert "ingress_metrics_collector" in kwargs diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 00000000..276732d2 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,1426 @@ +from pathlib import Path + +import pytest + +from pybotx.cli import main +from pybotx.scaffold import ( + CreateBotProjectOptions, + CreateCommandOptions, + CreateFSMFlowOptions, + CreatePortOptions, + CreateRepositoryOptions, + CreateServiceOptions, + CreateWidgetOptions, + CreateWidgetFlowOptions, + PROJECT_MANIFEST_FILENAME, + create_bot_project, + create_command_in_project, + create_fsm_flow_in_project, + create_port_in_project, + create_repository_in_project, + create_service_in_project, + create_widget_in_project, + create_widget_flow_in_project, +) + + +def _package_dir(root_dir: Path, package_name: str) -> Path: + return root_dir / "src" / package_name + + +def test__create_bot_project__creates_expected_file_tree(tmp_path: Path) -> None: + target_dir = tmp_path / "my-bot" + + result = create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + assert result.root_dir == target_dir + assert result.project_name == "my-bot" + assert result.package_name == "my_bot" + assert (target_dir / PROJECT_MANIFEST_FILENAME).is_file() + assert (target_dir / ".env.example").is_file() + assert (target_dir / ".gitignore").is_file() + assert (target_dir / "README.md").is_file() + assert (target_dir / "ARCHITECTURE.md").is_file() + assert (target_dir / "pyproject.toml").is_file() + assert (target_dir / "Dockerfile").is_file() + assert (target_dir / "docker-compose.yml").is_file() + assert (package_dir / "main.py").is_file() + assert (package_dir / "container.py").is_file() + assert (package_dir / "domain" / "__init__.py").is_file() + assert (package_dir / "domain" / "greeting_profile.py").is_file() + assert (package_dir / "domain" / "ports" / "__init__.py").is_file() + assert ( + package_dir / "domain" / "ports" / "greeting_profile_repository.py" + ).is_file() + assert (package_dir / "application" / "__init__.py").is_file() + assert (package_dir / "application" / "ports" / "__init__.py").is_file() + assert (package_dir / "application" / "services" / "__init__.py").is_file() + assert (package_dir / "application" / "services" / "greeting.py").is_file() + assert (package_dir / "infrastructure" / "__init__.py").is_file() + assert (package_dir / "infrastructure" / "config.py").is_file() + assert (package_dir / "infrastructure" / "repositories" / "__init__.py").is_file() + assert ( + package_dir + / "infrastructure" + / "repositories" + / "static_greeting_profile_repository.py" + ).is_file() + assert (package_dir / "presentation" / "__init__.py").is_file() + assert (package_dir / "presentation" / "api.py").is_file() + assert (package_dir / "presentation" / "bot.py").is_file() + assert (package_dir / "presentation" / "widget_support.py").is_file() + assert (package_dir / "presentation" / "commands" / "__init__.py").is_file() + assert (package_dir / "presentation" / "commands" / "common.py").is_file() + assert (target_dir / "tests" / "conftest.py").is_file() + assert (target_dir / "tests" / "test_architecture.py").is_file() + assert (target_dir / "tests" / "test_start_command.py").is_file() + + pyproject_content = (target_dir / "pyproject.toml").read_text(encoding="utf-8") + assert 'name = "my-bot"' in pyproject_content + assert '"pybotx==' in pyproject_content + assert '"dependency-injector>=4.48.1,<5"' in pyproject_content + assert '"opentelemetry-api>=1.38.0,<2"' in pyproject_content + + commands_init_content = ( + package_dir / "presentation" / "commands" / "__init__.py" + ).read_text(encoding="utf-8") + assert "# pybotx-scaffold command imports start" in commands_init_content + assert "# pybotx-scaffold command collectors start" in commands_init_content + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert "StaticGreetingProfileRepository" in container_content + assert "# pybotx-scaffold repository imports start" in container_content + assert "# pybotx-scaffold repository providers start" in container_content + assert "greeting_profile_repository = providers.Singleton(" in container_content + assert "greeting_profile_repository=greeting_profile_repository" in container_content + assert "# pybotx-scaffold service imports start" in container_content + assert "# pybotx-scaffold service providers start" in container_content + + greeting_service_content = ( + package_dir / "application" / "services" / "greeting.py" + ).read_text(encoding="utf-8") + assert "GreetingProfileRepository" in greeting_service_content + assert "self.greeting_profile_repository.get_profile()" in greeting_service_content + + greeting_repository_content = ( + package_dir / "domain" / "ports" / "greeting_profile_repository.py" + ).read_text(encoding="utf-8") + assert "class GreetingProfileRepository(Protocol):" in greeting_repository_content + + static_repository_content = ( + package_dir + / "infrastructure" + / "repositories" + / "static_greeting_profile_repository.py" + ).read_text(encoding="utf-8") + assert "class StaticGreetingProfileRepository:" in static_repository_content + assert "return GreetingProfile(" in static_repository_content + + main_content = (package_dir / "main.py").read_text(encoding="utf-8") + assert "from my_bot.presentation.api import create_app" in main_content + + api_content = (package_dir / "presentation" / "api.py").read_text( + encoding="utf-8", + ) + assert "create_fastapi_bot_app" in api_content + assert "FastAPIBotAppConfig(" in api_content + assert 'metrics_path="/metrics"' in api_content + + architecture_content = (target_dir / "ARCHITECTURE.md").read_text( + encoding="utf-8", + ) + assert "4-layer clean architecture" in architecture_content + assert "src/my_bot/container.py" in architecture_content + assert "presentation -> application -> domain" in architecture_content + assert "GreetingProfileRepository" in architecture_content + + conftest_content = (target_dir / "tests" / "conftest.py").read_text( + encoding="utf-8", + ) + assert "from my_bot.presentation.commands import collectors" in conftest_content + assert "from my_bot.container import ApplicationContainer" in conftest_content + assert "built_bot.send = AsyncMock" in conftest_content + assert "built_bot.edit_message = AsyncMock()" in conftest_content + + +def test__create_bot_project__supports_explicit_names_and_without_docker( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "generated" + + result = create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + project_name="customer-bot", + package_name="customer_bot", + display_name="Customer Support Bot", + description="Customer support bot", + enable_otel_tracing=True, + include_docker=False, + ), + ) + + assert result.project_name == "customer-bot" + assert result.package_name == "customer_bot" + assert not (target_dir / "Dockerfile").exists() + assert not (target_dir / "docker-compose.yml").exists() + + env_content = (target_dir / ".env.example").read_text(encoding="utf-8") + assert 'BOT_DISPLAY_NAME="Customer Support Bot"' in env_content + assert 'BOT_ENABLE_OTEL_TRACING="true"' in env_content + + readme_content = (target_dir / "README.md").read_text(encoding="utf-8") + assert "# customer-bot" in readme_content + assert "ARCHITECTURE.md" in readme_content + assert "create port" in readme_content + assert "create repository" in readme_content + assert "create fsm-flow" not in readme_content + assert "create widget" in readme_content + + +def test__create_bot_project__creates_fsm_template(tmp_path: Path) -> None: + target_dir = tmp_path / "fsm-bot" + + result = create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + package_dir = _package_dir(target_dir, "fsm_bot") + + assert result.template == "production-fastapi-fsm" + assert (target_dir / "ARCHITECTURE.md").is_file() + assert (package_dir / "presentation" / "fsm" / "__init__.py").is_file() + assert (package_dir / "presentation" / "fsm" / "login.py").is_file() + assert (package_dir / "infrastructure" / "fsm_state_repo.py").is_file() + assert ( + package_dir / "infrastructure" / "repositories" / "static_greeting_profile_repository.py" + ).is_file() + assert ( + package_dir / "domain" / "ports" / "greeting_profile_repository.py" + ).is_file() + assert (package_dir / "application" / "services" / "auth.py").is_file() + assert (package_dir / "presentation" / "widget_support.py").is_file() + + pyproject_content = (target_dir / "pyproject.toml").read_text(encoding="utf-8") + assert '"pybotx-fsm>=0.6.1,<0.7"' in pyproject_content + assert '"redis>=5.0.0,<6"' in pyproject_content + + env_content = (target_dir / ".env.example").read_text(encoding="utf-8") + assert 'BOT_FSM_REDIS_URL="redis://localhost:6379/0"' in env_content + + bot_content = (package_dir / "presentation" / "bot.py").read_text(encoding="utf-8") + assert "FSMMiddleware" in bot_content + assert 'state_repo_key="fsm_state_repo"' in bot_content + assert "# pybotx-scaffold fsm imports start" in bot_content + assert "# pybotx-scaffold fsm collectors start" in bot_content + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert "StaticGreetingProfileRepository" in container_content + assert "# pybotx-scaffold repository imports start" in container_content + assert "# pybotx-scaffold repository providers start" in container_content + assert "greeting_profile_repository = providers.Singleton(" in container_content + + api_content = (package_dir / "presentation" / "api.py").read_text( + encoding="utf-8", + ) + assert "fsm_redis" in api_content + assert "ReadinessCheck(" in api_content + + conftest_content = (target_dir / "tests" / "conftest.py").read_text( + encoding="utf-8", + ) + assert "from fsm_bot.presentation.commands import collectors" in conftest_content + assert "from fsm_bot.presentation.fsm.login import fsm as login_fsm" in ( + conftest_content + ) + assert "# pybotx-scaffold fsm imports start" in conftest_content + assert "# pybotx-scaffold fsm collectors start" in conftest_content + assert "built_bot.send = AsyncMock" in conftest_content + assert "built_bot.edit_message = AsyncMock()" in conftest_content + + readme_content = (target_dir / "README.md").read_text(encoding="utf-8") + assert "create port" in readme_content + assert "create repository" in readme_content + assert "create widget" in readme_content + assert "create fsm-flow" in readme_content + + +def test__create_command_in_project__creates_expected_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_command_in_project( + CreateCommandOptions( + command_name="ping-users", + project_dir=target_dir, + ), + ) + + assert result.command_path == "/ping-users" + assert (package_dir / "presentation" / "commands" / "ping_users.py").is_file() + assert (package_dir / "application" / "services" / "ping_users.py").is_file() + assert (target_dir / "tests" / "test_ping_users.py").is_file() + + commands_init_content = ( + package_dir / "presentation" / "commands" / "__init__.py" + ).read_text(encoding="utf-8") + assert ( + "from my_bot.presentation.commands.ping_users import collector as ping_users_collector" + in commands_init_content + ) + assert "ping_users_collector," in commands_init_content + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.application.services.ping_users import PingUsersService" + in container_content + ) + assert "ping_users_service = providers.Factory(PingUsersService)" in container_content + + command_content = ( + package_dir / "presentation" / "commands" / "ping_users.py" + ).read_text(encoding="utf-8") + assert "@collector.command(" in command_content + assert "/ping-users" in command_content + assert "ping_users_service()" in command_content + assert "from my_bot.container import ApplicationContainer" in command_content + + +def test__create_command_in_project__works_for_fsm_template(tmp_path: Path) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + package_dir = _package_dir(target_dir, "fsm_bot") + + result = create_command_in_project( + CreateCommandOptions(command_name="/ping-users", project_dir=target_dir), + ) + + assert result.command_path == "/ping-users" + + commands_init_content = ( + package_dir / "presentation" / "commands" / "__init__.py" + ).read_text(encoding="utf-8") + assert "login_fsm" in commands_init_content + assert "ping_users_collector" in commands_init_content + + +def test__create_command_in_project__rejects_duplicate_command( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_command_in_project( + CreateCommandOptions(command_name="ping-users", project_dir=target_dir), + ) + + with pytest.raises(FileExistsError): + create_command_in_project( + CreateCommandOptions(command_name="ping-users", project_dir=target_dir), + ) + + +def test__create_service_in_project__creates_expected_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_service_in_project( + CreateServiceOptions( + service_name="sync-users", + project_dir=target_dir, + ), + ) + + assert result.service_name == "sync_users" + assert (package_dir / "application" / "services" / "sync_users.py").is_file() + assert (target_dir / "tests" / "test_sync_users_service.py").is_file() + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.application.services.sync_users import SyncUsersService" + in container_content + ) + assert "sync_users_service = providers.Factory(SyncUsersService)" in container_content + + service_content = ( + package_dir / "application" / "services" / "sync_users.py" + ).read_text(encoding="utf-8") + assert "class SyncUsersService:" in service_content + assert "return 'Implement sync-users service.'" in service_content + + test_content = (target_dir / "tests" / "test_sync_users_service.py").read_text( + encoding="utf-8", + ) + assert "from my_bot.application.services.sync_users import SyncUsersService" in test_content + assert "assert service.handle() == 'Implement sync-users service.'" in test_content + + +def test__create_service_in_project__rejects_duplicate_service( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_service_in_project( + CreateServiceOptions(service_name="sync-users", project_dir=target_dir), + ) + + with pytest.raises(FileExistsError): + create_service_in_project( + CreateServiceOptions(service_name="sync-users", project_dir=target_dir), + ) + + +def test__create_port_in_project__creates_expected_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_port_in_project( + CreatePortOptions( + port_name="billing-gateway", + project_dir=target_dir, + ), + ) + + assert result.port_name == "billing_gateway" + assert (package_dir / "domain" / "ports" / "billing_gateway.py").is_file() + + port_content = ( + package_dir / "domain" / "ports" / "billing_gateway.py" + ).read_text(encoding="utf-8") + assert "class BillingGatewayPort(Protocol):" in port_content + assert "Domain port for `billing-gateway`." in port_content + + +def test__create_port_in_project__rejects_duplicate_port( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_port_in_project( + CreatePortOptions(port_name="billing-gateway", project_dir=target_dir), + ) + + with pytest.raises(FileExistsError): + create_port_in_project( + CreatePortOptions(port_name="billing-gateway", project_dir=target_dir), + ) + + +def test__create_repository_in_project__creates_expected_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_repository_in_project( + CreateRepositoryOptions( + repository_name="user-profile", + project_dir=target_dir, + ), + ) + + assert result.repository_name == "user_profile_repository" + assert result.port_name == "user_profile_repository" + assert ( + package_dir / "domain" / "ports" / "user_profile_repository.py" + ).is_file() + assert ( + package_dir + / "infrastructure" + / "repositories" + / "stub_user_profile_repository.py" + ).is_file() + assert (target_dir / "tests" / "test_user_profile_repository.py").is_file() + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.infrastructure.repositories.stub_user_profile_repository " + "import StubUserProfileRepository" + ) in container_content + assert ( + "user_profile_repository = providers.Singleton(StubUserProfileRepository)" + in container_content + ) + + port_content = ( + package_dir / "domain" / "ports" / "user_profile_repository.py" + ).read_text(encoding="utf-8") + assert "class UserProfileRepository(Protocol):" in port_content + + repository_content = ( + package_dir + / "infrastructure" + / "repositories" + / "stub_user_profile_repository.py" + ).read_text(encoding="utf-8") + assert "class StubUserProfileRepository:" in repository_content + + test_content = (target_dir / "tests" / "test_user_profile_repository.py").read_text( + encoding="utf-8", + ) + assert "container.user_profile_repository()" in test_content + assert "isinstance(repository, StubUserProfileRepository)" in test_content + + +def test__create_repository_in_project__binds_to_existing_port( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + create_port_in_project( + CreatePortOptions( + port_name="billing-gateway", + project_dir=target_dir, + ), + ) + + result = create_repository_in_project( + CreateRepositoryOptions( + repository_name="stripe-billing-gateway", + port_name="billing-gateway", + project_dir=target_dir, + ), + ) + + assert result.repository_name == "stripe_billing_gateway_repository" + assert result.port_name == "billing_gateway" + assert ( + package_dir + / "infrastructure" + / "repositories" + / "stub_stripe_billing_gateway_repository.py" + ).is_file() + assert not ( + package_dir / "domain" / "ports" / "stripe_billing_gateway_repository.py" + ).exists() + assert (target_dir / "tests" / "test_stripe_billing_gateway_repository.py").is_file() + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.infrastructure.repositories.stub_stripe_billing_gateway_repository " + "import StubStripeBillingGatewayRepository" + ) in container_content + assert ( + "billing_gateway = providers.Singleton(StubStripeBillingGatewayRepository)" + in container_content + ) + + repository_content = ( + package_dir + / "infrastructure" + / "repositories" + / "stub_stripe_billing_gateway_repository.py" + ).read_text(encoding="utf-8") + assert "the `BillingGatewayPort` port" in repository_content + + +def test__create_repository_in_project__rejects_missing_port( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + with pytest.raises(FileNotFoundError): + create_repository_in_project( + CreateRepositoryOptions( + repository_name="stripe-billing-gateway", + port_name="billing-gateway", + project_dir=target_dir, + ), + ) + + +def test__create_repository_in_project__rejects_duplicate_repository( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_repository_in_project( + CreateRepositoryOptions(repository_name="user-profile", project_dir=target_dir), + ) + + with pytest.raises(FileExistsError): + create_repository_in_project( + CreateRepositoryOptions(repository_name="user-profile", project_dir=target_dir), + ) + + +def test__create_fsm_flow_in_project__creates_expected_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + package_dir = _package_dir(target_dir, "fsm_bot") + + result = create_fsm_flow_in_project( + CreateFSMFlowOptions(flow_name="approval-step", project_dir=target_dir), + ) + + assert result.flow_name == "approval-step" + assert result.command_path == "/approval-step" + assert (package_dir / "presentation" / "fsm" / "approval_step.py").is_file() + assert (package_dir / "presentation" / "commands" / "approval_step.py").is_file() + assert (target_dir / "tests" / "test_approval_step_fsm.py").is_file() + + flow_content = ( + package_dir / "presentation" / "fsm" / "approval_step.py" + ).read_text(encoding="utf-8") + assert "class ApprovalStepStates(Enum):" in flow_content + assert "start_approval_step_flow" in flow_content + + command_content = ( + package_dir / "presentation" / "commands" / "approval_step.py" + ).read_text(encoding="utf-8") + assert ( + "from fsm_bot.presentation.fsm.approval_step import start_approval_step_flow" + in command_content + ) + assert "/approval-step" in command_content + + commands_init_content = ( + package_dir / "presentation" / "commands" / "__init__.py" + ).read_text(encoding="utf-8") + assert ( + "from fsm_bot.presentation.commands.approval_step import collector as approval_step_collector" + in commands_init_content + ) + assert "approval_step_collector," in commands_init_content + + bot_content = (package_dir / "presentation" / "bot.py").read_text(encoding="utf-8") + assert ( + "from fsm_bot.presentation.fsm.approval_step import fsm as approval_step_fsm" + in bot_content + ) + assert "approval_step_fsm," in bot_content + + conftest_content = (target_dir / "tests" / "conftest.py").read_text( + encoding="utf-8", + ) + assert ( + "from fsm_bot.presentation.fsm.approval_step import fsm as approval_step_fsm" + in conftest_content + ) + assert "approval_step_fsm," in conftest_content + + +def test__create_fsm_flow_in_project__upgrades_legacy_fsm_scaffold( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + + bot_path = target_dir / "src" / "fsm_bot" / "presentation" / "bot.py" + bot_path.write_text( + bot_path.read_text(encoding="utf-8") + .replace( + "\n# pybotx-scaffold fsm imports start\n# pybotx-scaffold fsm imports end", + "", + ) + .replace( + "\n".join( + [ + " middlewares=[", + " FSMMiddleware(", + " [", + " login_fsm,", + " # pybotx-scaffold fsm collectors start", + " # pybotx-scaffold fsm collectors end", + " ],", + ' state_repo_key="fsm_state_repo",', + " ),", + " ],", + ], + ), + ' middlewares=[FSMMiddleware([login_fsm], state_repo_key="fsm_state_repo")],', + ), + encoding="utf-8", + ) + + conftest_path = target_dir / "tests" / "conftest.py" + conftest_path.write_text( + conftest_path.read_text(encoding="utf-8") + .replace( + "\n# pybotx-scaffold fsm imports start\n# pybotx-scaffold fsm imports end", + "", + ) + .replace( + "\n".join( + [ + " middlewares=[", + " FSMMiddleware(", + " [", + " login_fsm,", + " # pybotx-scaffold fsm collectors start", + " # pybotx-scaffold fsm collectors end", + " ],", + ' state_repo_key="fsm_state_repo",', + " ),", + " ],", + ], + ), + ' middlewares=[FSMMiddleware([login_fsm], state_repo_key="fsm_state_repo")],', + ), + encoding="utf-8", + ) + + create_fsm_flow_in_project( + CreateFSMFlowOptions(flow_name="legacy-flow", project_dir=target_dir), + ) + + upgraded_bot_content = bot_path.read_text(encoding="utf-8") + assert "# pybotx-scaffold fsm imports start" in upgraded_bot_content + assert "legacy_flow_fsm," in upgraded_bot_content + + upgraded_conftest_content = conftest_path.read_text(encoding="utf-8") + assert "# pybotx-scaffold fsm collectors start" in upgraded_conftest_content + assert "legacy_flow_fsm," in upgraded_conftest_content + + +def test__create_fsm_flow_in_project__rejects_non_fsm_template( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + with pytest.raises(ValueError, match="production-fastapi-fsm"): + create_fsm_flow_in_project( + CreateFSMFlowOptions(flow_name="approval", project_dir=target_dir), + ) + + +def test__create_widget_in_project__creates_confirm_widget_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="deployment-approval", + project_dir=target_dir, + kind="confirm", + ), + ) + + assert result.widget_name == "deployment-approval" + assert result.kind == "confirm" + assert result.command_path == "/deployment-approval" + assert (package_dir / "presentation" / "commands" / "deployment_approval.py").is_file() + assert (package_dir / "application" / "services" / "deployment_approval.py").is_file() + assert (target_dir / "tests" / "test_deployment_approval_widget.py").is_file() + + command_content = ( + package_dir / "presentation" / "commands" / "deployment_approval.py" + ).read_text(encoding="utf-8") + assert "build_widget_factory" in command_content + assert "build_widget_runner_config" in command_content + assert "return widgets.confirm(" in command_content + assert "from my_bot.presentation.widget_support import (" in command_content + + service_content = ( + package_dir / "application" / "services" / "deployment_approval.py" + ).read_text(encoding="utf-8") + assert "class DeploymentApprovalWidgetService:" in service_content + assert "Confirm deployment approval?" in service_content + + commands_init_content = ( + package_dir / "presentation" / "commands" / "__init__.py" + ).read_text(encoding="utf-8") + assert ( + "from my_bot.presentation.commands.deployment_approval import collector as deployment_approval_collector" + in commands_init_content + ) + assert "deployment_approval_collector," in commands_init_content + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.application.services.deployment_approval import DeploymentApprovalWidgetService" + in container_content + ) + assert ( + "deployment_approval_widget_service = providers.Factory(DeploymentApprovalWidgetService)" + in container_content + ) + + +def test__create_widget_in_project__supports_select_widget_for_fsm_template( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + package_dir = _package_dir(target_dir, "fsm_bot") + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="environment-picker", + project_dir=target_dir, + kind="select", + ), + ) + + assert result.kind == "select" + command_content = ( + package_dir / "presentation" / "commands" / "environment_picker.py" + ).read_text(encoding="utf-8") + assert "return widgets.select(" in command_content + assert "SELECT_VALUE_KEY" in command_content + + test_content = (target_dir / "tests" / "test_environment_picker_widget.py").read_text( + encoding="utf-8", + ) + assert 'message.data = {SELECT_VALUE_KEY: "prod"}' in test_content + + +def test__create_widget_in_project__supports_search_select_widget( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="service-picker", + project_dir=target_dir, + kind="search-select", + ), + ) + + assert result.kind == "search-select" + command_content = ( + package_dir / "presentation" / "commands" / "service_picker.py" + ).read_text(encoding="utf-8") + assert "SearchSelectWidget" in command_content + assert "return widgets.search_select(" in command_content + + test_content = (target_dir / "tests" / "test_service_picker_widget.py").read_text( + encoding="utf-8", + ) + assert 'message.data = {SELECT_VALUE_KEY: "prod"}' in test_content + + +def test__create_widget_in_project__supports_multi_select_widget( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="environment-batch", + project_dir=target_dir, + kind="multi-select", + ), + ) + + assert result.kind == "multi-select" + command_content = ( + package_dir / "presentation" / "commands" / "environment_batch.py" + ).read_text(encoding="utf-8") + assert "MultiSelectWidget" in command_content + assert "RunnerHookResult" in command_content + assert "return widgets.multi_select(" in command_content + + service_content = ( + package_dir / "application" / "services" / "environment_batch.py" + ).read_text(encoding="utf-8") + assert "def max_selected(self) -> int:" in service_content + assert "def is_selection_complete(self, values: list[str]) -> bool:" in ( + service_content + ) + + test_content = (target_dir / "tests" / "test_environment_batch_widget.py").read_text( + encoding="utf-8", + ) + assert "MULTI_SELECTED_VALUES_KEY" in test_content + assert 'message.data = {MULTI_SELECT_VALUE_KEY: "prod"}' in test_content + + +def test__create_widget_in_project__supports_date_range_widget( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="deployment-window", + project_dir=target_dir, + kind="date-range", + ), + ) + + assert result.kind == "date-range" + command_content = ( + package_dir / "presentation" / "commands" / "deployment_window.py" + ).read_text(encoding="utf-8") + assert "DateRangeWidget" in command_content + assert "DATE_RANGE_SELECTED_DATE_KEY" in command_content + assert "return widgets.date_range(" in command_content + + service_content = ( + package_dir / "application" / "services" / "deployment_window.py" + ).read_text(encoding="utf-8") + assert "from datetime import date" in service_content + assert "def build_selection_result(self, start_date: date, end_date: date) -> str:" in ( + service_content + ) + + test_content = (target_dir / "tests" / "test_deployment_window_widget.py").read_text( + encoding="utf-8", + ) + assert "DATE_RANGE_START_KEY" in test_content + assert 'message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-03-08"}' in test_content + + +def test__create_widget_in_project__creates_widget_support_for_legacy_scaffold( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + widget_support_path = ( + target_dir / "src" / "my_bot" / "presentation" / "widget_support.py" + ) + widget_support_path.unlink() + + conftest_path = target_dir / "tests" / "conftest.py" + conftest_path.write_text( + conftest_path.read_text(encoding="utf-8") + .replace( + ' built_bot.send = AsyncMock(return_value=uuid4()) # type: ignore[method-assign]\n', + "", + ) + .replace( + " built_bot.edit_message = AsyncMock() # type: ignore[method-assign]\n", + "", + ), + encoding="utf-8", + ) + + result = create_widget_in_project( + CreateWidgetOptions( + widget_name="legacy-widget", + project_dir=target_dir, + kind="approval", + ), + ) + + assert widget_support_path in result.created_files + assert widget_support_path.is_file() + + upgraded_conftest_content = conftest_path.read_text(encoding="utf-8") + assert "built_bot.send = AsyncMock" in upgraded_conftest_content + assert "built_bot.edit_message = AsyncMock()" in upgraded_conftest_content + + +def test__create_widget_flow_in_project__creates_confirm_flow_artifacts( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name="deployment-approval", + project_dir=target_dir, + kind="confirm", + ), + ) + + assert result.flow_name == "deployment-approval" + assert result.kind == "confirm" + assert result.command_path == "/deployment-approval" + assert (package_dir / "presentation" / "commands" / "deployment_approval.py").is_file() + assert ( + package_dir / "application" / "services" / "deployment_approval_widget.py" + ).is_file() + assert ( + package_dir / "application" / "services" / "deployment_approval_result.py" + ).is_file() + assert (target_dir / "tests" / "test_deployment_approval_widget_flow.py").is_file() + + command_content = ( + package_dir / "presentation" / "commands" / "deployment_approval.py" + ).read_text(encoding="utf-8") + assert "deployment_approval_widget_service()" in command_content + assert "deployment_approval_result_service()" in command_content + assert "return widgets.confirm(" in command_content + + container_content = (package_dir / "container.py").read_text(encoding="utf-8") + assert ( + "from my_bot.application.services.deployment_approval_widget import DeploymentApprovalWidgetService" + in container_content + ) + assert ( + "from my_bot.application.services.deployment_approval_result import DeploymentApprovalResultService" + in container_content + ) + assert ( + "deployment_approval_widget_service = providers.Factory(DeploymentApprovalWidgetService)" + in container_content + ) + assert ( + "deployment_approval_result_service = providers.Factory(DeploymentApprovalResultService)" + in container_content + ) + + +def test__create_widget_flow_in_project__supports_multi_select_flow( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name="environment-batch", + project_dir=target_dir, + kind="multi-select", + ), + ) + + assert result.kind == "multi-select" + command_content = ( + package_dir / "presentation" / "commands" / "environment_batch.py" + ).read_text(encoding="utf-8") + assert "RunnerHookResult" in command_content + assert "container.environment_batch_result_service().handle_selection" in command_content + assert "return widgets.multi_select(" in command_content + + widget_service_content = ( + package_dir / "application" / "services" / "environment_batch_widget.py" + ).read_text(encoding="utf-8") + assert "def max_selected(self) -> int:" in widget_service_content + + result_service_content = ( + package_dir / "application" / "services" / "environment_batch_result.py" + ).read_text(encoding="utf-8") + assert "def handle_selection(self, values: list[str]) -> str:" in ( + result_service_content + ) + + +def test__create_widget_flow_in_project__supports_date_range_flow( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + package_dir = _package_dir(target_dir, "my_bot") + + result = create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name="deployment-window", + project_dir=target_dir, + kind="date-range", + ), + ) + + assert result.kind == "date-range" + command_content = ( + package_dir / "presentation" / "commands" / "deployment_window.py" + ).read_text(encoding="utf-8") + assert "DateRangeWidget" in command_content + assert "DATE_RANGE_SELECTED_DATE_KEY" in command_content + assert ".handle_selection(widget.start, widget.end)" in command_content + + result_service_content = ( + package_dir / "application" / "services" / "deployment_window_result.py" + ).read_text(encoding="utf-8") + assert "from datetime import date" in result_service_content + assert "def handle_selection(self, start_date: date, end_date: date) -> str:" in ( + result_service_content + ) + + +def test__create_widget_in_project__rejects_duplicate_widget( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_widget_in_project( + CreateWidgetOptions( + widget_name="deployment-approval", + project_dir=target_dir, + ), + ) + + with pytest.raises(FileExistsError): + create_widget_in_project( + CreateWidgetOptions( + widget_name="deployment-approval", + project_dir=target_dir, + ), + ) + + +def test__create_widget_flow_in_project__rejects_duplicate_flow( + tmp_path: Path, +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name="deployment-approval", + project_dir=target_dir, + ), + ) + + with pytest.raises(FileExistsError): + create_widget_flow_in_project( + CreateWidgetFlowOptions( + flow_name="deployment-approval", + project_dir=target_dir, + ), + ) + + +def test__create_bot_project__rejects_non_empty_dir(tmp_path: Path) -> None: + target_dir = tmp_path / "busy" + target_dir.mkdir() + (target_dir / "already.txt").write_text("occupied", encoding="utf-8") + + with pytest.raises(FileExistsError): + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + +def test__create_bot_project__rejects_invalid_package_name(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="should not start with a digit"): + create_bot_project( + CreateBotProjectOptions( + target_dir=tmp_path / "bot", + package_name="123bot", + ), + ) + + +def test__cli_main__creates_project_in_current_directory_by_default( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + + exit_code = main(["create", "bot"]) + + assert exit_code == 0 + assert (tmp_path / "pyproject.toml").is_file() + assert (tmp_path / "README.md").is_file() + generated_packages = [path for path in (tmp_path / "src").iterdir() if path.is_dir()] + assert len(generated_packages) == 1 + + stdout = capsys.readouterr().out + assert "Created pybotx bot project" in stdout + + +def test__cli_main__creates_command_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "command", + "ping-users", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "my_bot" / "presentation" / "commands" / "ping_users.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created command /ping-users" in stdout + + +def test__cli_main__creates_port_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "port", + "billing-gateway", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "my_bot" / "domain" / "ports" / "billing_gateway.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created port billing_gateway" in stdout + + +def test__cli_main__creates_repository_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "repository", + "user-profile", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "my_bot" / "domain" / "ports" / "user_profile_repository.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created repository user_profile_repository bound to port user_profile_repository" in stdout + + +def test__cli_main__creates_repository_bound_to_existing_port( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + create_port_in_project( + CreatePortOptions( + port_name="billing-gateway", + project_dir=target_dir, + ), + ) + + exit_code = main( + [ + "create", + "repository", + "stripe-billing-gateway", + "--port", + "billing-gateway", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir + / "src" + / "my_bot" + / "infrastructure" + / "repositories" + / "stub_stripe_billing_gateway_repository.py" + ).is_file() + stdout = capsys.readouterr().out + assert ( + "Created repository stripe_billing_gateway_repository bound to port billing_gateway" + in stdout + ) + + +def test__cli_main__creates_service_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "service", + "sync-users", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "my_bot" / "application" / "services" / "sync_users.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created application service sync_users" in stdout + + +def test__cli_main__creates_service_via_use_case_alias( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "use-case", + "sync-users", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "my_bot" / "application" / "services" / "sync_users.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created application service sync_users" in stdout + + +def test__cli_main__creates_fsm_flow_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + + exit_code = main( + [ + "create", + "fsm-flow", + "approval", + "--project-dir", + str(target_dir), + ], + ) + + assert exit_code == 0 + assert ( + target_dir / "src" / "fsm_bot" / "presentation" / "fsm" / "approval.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created FSM flow approval" in stdout + + +def test__cli_main__creates_widget_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "widget", + "deployment-approval", + "--project-dir", + str(target_dir), + "--kind", + "confirm", + ], + ) + + assert exit_code == 0 + assert ( + target_dir + / "src" + / "my_bot" + / "presentation" + / "commands" + / "deployment_approval.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created confirm widget deployment-approval" in stdout + + +def test__cli_main__creates_widget_flow_in_generated_project( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + target_dir = tmp_path / "my-bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + + exit_code = main( + [ + "create", + "widget-flow", + "deployment-approval", + "--project-dir", + str(target_dir), + "--kind", + "confirm", + ], + ) + + assert exit_code == 0 + assert ( + target_dir + / "src" + / "my_bot" + / "application" + / "services" + / "deployment_approval_result.py" + ).is_file() + stdout = capsys.readouterr().out + assert "Created confirm widget flow deployment-approval" in stdout + + +def test__cli_main__returns_error_for_non_empty_current_directory( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "existing.txt").write_text("busy", encoding="utf-8") + + exit_code = main(["create", "bot"]) + + assert exit_code == 1 + stderr = capsys.readouterr().err + assert "isn't empty" in stderr diff --git a/tests/test_scaffold_edge_cases.py b/tests/test_scaffold_edge_cases.py new file mode 100644 index 00000000..a0063edb --- /dev/null +++ b/tests/test_scaffold_edge_cases.py @@ -0,0 +1,334 @@ +from argparse import Namespace +import importlib.metadata +from pathlib import Path +import runpy +from types import SimpleNamespace +from typing import Any + +import pytest + +from pybotx import cli +from pybotx.scaffold import CreateBotProjectOptions, create_bot_project +from pybotx.scaffold import _shared, fsm_flow, manifest, project, repository, widget +from pybotx.scaffold import widget_flow + + +@pytest.mark.parametrize( + ("argv", "factory_name"), + [ + (["create", "command", "name"], "create_command_in_project"), + (["create", "port", "name"], "create_port_in_project"), + (["create", "repository", "name"], "create_repository_in_project"), + (["create", "service", "name"], "create_service_in_project"), + (["create", "fsm-flow", "name"], "create_fsm_flow_in_project"), + (["create", "widget", "name"], "create_widget_in_project"), + (["create", "widget-flow", "name"], "create_widget_flow_in_project"), + ], +) +def test__cli_create_handlers__report_scaffold_errors( + argv: list[str], + factory_name: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fail(_options: object) -> None: + raise ValueError("invalid scaffold") + + monkeypatch.setattr(cli, factory_name, fail) + + assert cli.main(argv) == 1 + assert "Error: invalid scaffold" in capsys.readouterr().err + + +def test__cli_main__returns_fallback_when_parser_error_returns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + parser = SimpleNamespace( + parse_args=lambda _argv: Namespace(command=None), + error=lambda _message: None, + ) + monkeypatch.setattr(cli, "build_parser", lambda: parser) + + assert cli.main([]) == 2 + + +def test__cli_module__is_executable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.argv", ["pybotx", "--help"]) + + with pytest.raises(SystemExit, match="0"): + runpy.run_module("pybotx.cli", run_name="__main__") + + +@pytest.mark.parametrize("raw_name", ["", "1-name", "class"]) +def test__normalize_scaffold_name__rejects_invalid_names(raw_name: str) -> None: + with pytest.raises(ValueError): + _shared.normalize_scaffold_name(raw_name, kind="Name") + + +def test__normalize_scaffold_name__rejects_non_identifier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_shared, "slug_to_module_name", lambda _name: "not-valid") + + with pytest.raises(ValueError, match="Invalid"): + _shared.normalize_scaffold_name("name", kind="Name") + + +@pytest.mark.parametrize( + "content", + ["plain text", "# end\n# start\n"], +) +def test__append_marked_lines__rejects_invalid_markers( + tmp_path: Path, + content: str, +) -> None: + path = tmp_path / "module.py" + path.write_text(content, encoding="utf-8") + + with pytest.raises(RuntimeError): + _shared.append_marked_lines( + path, + start_marker="# start", + end_marker="# end", + new_lines=["line"], + ) + + +def test__append_marked_lines__rejects_duplicate(tmp_path: Path) -> None: + path = tmp_path / "module.py" + path.write_text("# start\nline\n# end", encoding="utf-8") + + with pytest.raises(FileExistsError): + _shared.append_marked_lines( + path, + start_marker="# start", + end_marker="# end", + new_lines=["line"], + ) + + +def test__flat_manifest__exposes_flat_paths_and_modules() -> None: + item = manifest.ScaffoldProjectManifest(template="legacy", package_name="sample") + + assert item.commands_dir == Path("src/sample/commands") + assert item.fsm_dir == Path("src/sample/fsm") + assert item.widget_support_path == Path("src/sample/widget_support.py") + assert item.services_dir == Path("src/sample/services") + assert item.bot_path == Path("src/sample/bot.py") + assert item.config_path == Path("src/sample/config.py") + assert item.api_path == Path("src/sample/main.py") + assert item.fsm_state_repo_path == Path("src/sample/fsm_state_repo.py") + assert item.root_main_module == "sample.main" + assert item.uses_clean_architecture is False + assert item._module_for_file(Path("src/sample/__init__.py")) == "sample" + assert set(item.layer_paths) == { + "domain", + "application", + "infrastructure", + "presentation", + } + + +def test__manifest_load__rejects_unknown_schema(tmp_path: Path) -> None: + (tmp_path / manifest.PROJECT_MANIFEST_FILENAME).write_text( + '{"schema_version": 999, "template": "x", "package_name": "x"}', + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="Unsupported scaffold manifest"): + manifest.ScaffoldProjectManifest.load(tmp_path) + + +def test__find_scaffold_project_root__supports_file_and_not_found(tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + source = project_dir / "source.py" + source.write_text("", encoding="utf-8") + (project_dir / manifest.PROJECT_MANIFEST_FILENAME).write_text("{}", encoding="utf-8") + + assert manifest.find_scaffold_project_root(source) == project_dir + with pytest.raises(FileNotFoundError): + manifest.find_scaffold_project_root(tmp_path / "missing") + + +def test__project_validation_edge_cases( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError, match="Unsupported template"): + project._validate_template("unknown") + + target_file = tmp_path / "target" + target_file.write_text("", encoding="utf-8") + with pytest.raises(FileExistsError, match="isn't a directory"): + project._ensure_target_dir_is_safe(target_file) + + for raw_name in ("", "class"): + with pytest.raises(ValueError): + project._normalize_package_name(raw_name) + + class InvalidRegex: + def sub(self, *_args: Any) -> str: + return "not-valid" + + monkeypatch.setattr(project, "_NON_ALNUM_RE", InvalidRegex()) + with pytest.raises(ValueError, match="Invalid package"): + project._normalize_package_name("name") + + +def test__project_name_and_display_name_cannot_be_derived( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(project, "_ensure_target_dir_is_safe", lambda _path: None) + with pytest.raises(ValueError, match="Project name"): + create_bot_project(CreateBotProjectOptions(target_dir=Path("/"))) + + with pytest.raises(ValueError, match="Display name"): + project._build_display_name("---") + + +def test__resolve_pybotx_version__falls_back_to_pyproject( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing(_name: str) -> str: + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(importlib.metadata, "version", missing) + assert project._resolve_pybotx_version() + + monkeypatch.setattr(project, "_VERSION_RE", SimpleNamespace(search=lambda _text: None)) + with pytest.raises(RuntimeError, match="current pybotx version"): + project._resolve_pybotx_version() + + +def test__fsm_flow_edge_cases(tmp_path: Path) -> None: + spec = fsm_flow._build_fsm_flow_spec( + flow_name="approval", + command_name=None, + description=None, + initial_prompt=None, + completion_text="Done", + ) + assert spec.completion_statement == "await bot.answer_message('Done')" + + current_import = "from pkg.presentation.fsm.login import fsm as login_fsm" + legacy_import = "from pkg.fsm.login import fsm as login_fsm" + middleware = ' middlewares=[FSMMiddleware([login_fsm], state_repo_key="fsm_state_repo")],' + for import_line in (legacy_import,): + path = tmp_path / "legacy.py" + path.write_text(f"{import_line}\n{middleware}\n", encoding="utf-8") + fsm_flow._ensure_fsm_markers( + path, + fsm_module_prefix="pkg.presentation.fsm", + package_name="pkg", + ) + assert fsm_flow._FSM_IMPORTS_START in path.read_text(encoding="utf-8") + + broken = tmp_path / "broken.py" + broken.write_text(current_import, encoding="utf-8") + with pytest.raises(RuntimeError, match="Couldn't enable FSM"): + fsm_flow._ensure_fsm_markers( + broken, + fsm_module_prefix="pkg.presentation.fsm", + package_name="pkg", + ) + + +def test__create_fsm_flow__rejects_duplicate_artifact(tmp_path: Path) -> None: + target_dir = tmp_path / "fsm-bot" + create_bot_project( + CreateBotProjectOptions( + target_dir=target_dir, + template="production-fastapi-fsm", + ), + ) + options = fsm_flow.CreateFSMFlowOptions(flow_name="approval", project_dir=target_dir) + fsm_flow.create_fsm_flow_in_project(options) + + with pytest.raises(FileExistsError): + fsm_flow.create_fsm_flow_in_project(options) + + +def test__fsm_marker_upgrade_rejects_missing_import(tmp_path: Path) -> None: + path = tmp_path / "broken.py" + path.write_text( + ' middlewares=[FSMMiddleware([login_fsm], state_repo_key="fsm_state_repo")],', + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="Couldn't enable FSM"): + fsm_flow._ensure_fsm_markers( + path, + fsm_module_prefix="pkg.presentation.fsm", + package_name="pkg", + ) + + +def test__widget_flow_variants_and_mock_upgrade(tmp_path: Path) -> None: + for kind in ("approval", "search-select", "select"): + spec = widget_flow._build_widget_flow_spec( + flow_name="release", + kind=kind, + command_name=None, + description=None, + ) + assert spec.kind == kind + assert len(widget_flow._select_widget_flow_templates(kind)) == 4 + + conftest = tmp_path / "conftest.py" + conftest.write_text(widget_flow._ANSWER_MESSAGE_MOCK, encoding="utf-8") + widget_flow._ensure_widget_test_mocks(conftest) + assert widget_flow._SEND_MESSAGE_MOCK in conftest.read_text(encoding="utf-8") + + broken = tmp_path / "broken.py" + broken.write_text("", encoding="utf-8") + with pytest.raises(RuntimeError, match="widget test support"): + widget_flow._ensure_widget_test_mocks(broken) + with pytest.raises(RuntimeError, match="widget test support"): + widget._ensure_widget_test_mocks(broken) + + +def test__widget_flow__creates_missing_widget_support(tmp_path: Path) -> None: + target_dir = tmp_path / "bot" + create_bot_project(CreateBotProjectOptions(target_dir=target_dir)) + support_path = target_dir / "src" / "bot" / "presentation" / "widget_support.py" + support_path.unlink() + + result = widget_flow.create_widget_flow_in_project( + widget_flow.CreateWidgetFlowOptions( + flow_name="approval", + project_dir=target_dir, + ), + ) + + assert support_path in result.created_files + + +def test__repository_marker_resolution__supports_fallback_and_errors( + tmp_path: Path, +) -> None: + path = tmp_path / "container.py" + path.write_text("fallback-start\nfallback-end\n", encoding="utf-8") + assert repository._resolve_container_markers( + path, + preferred_start="preferred-start", + preferred_end="preferred-end", + fallback_start="fallback-start", + fallback_end="fallback-end", + ) == ("fallback-start", "fallback-end") + + path.write_text("", encoding="utf-8") + with pytest.raises(RuntimeError, match="scaffold markers"): + repository._resolve_container_markers( + path, + preferred_start="preferred-start", + preferred_end="preferred-end", + fallback_start="fallback-start", + fallback_end="fallback-end", + ) + + spec = repository._build_repository_spec( + repository_name="sql-users", + port_name="users-repository", + ) + assert spec.port_class_name == "UsersRepository" diff --git a/tests/widgets/test_approval.py b/tests/widgets/test_approval.py new file mode 100644 index 00000000..eb91b756 --- /dev/null +++ b/tests/widgets/test_approval.py @@ -0,0 +1,57 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.approval import ( + APPROVAL_ACTION_KEY, + APPROVE_ACTION, + COMMENT_ACTION, + REJECT_ACTION, + ApprovalWidget, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__approval_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = ApprovalWidget( + label="Согласовать заявку?", + message=message, + bot=bot, + command="/approval", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + assert rows == [["Согласовать", "Отклонить"], ["Комментарий"]] + + +def test__approval_widget__actions( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {APPROVAL_ACTION_KEY: APPROVE_ACTION} + assert ApprovalWidget.get_action(message) == APPROVE_ACTION + assert ApprovalWidget.is_approved(message) + + message.data = {APPROVAL_ACTION_KEY: REJECT_ACTION} + assert ApprovalWidget.get_action(message) == REJECT_ACTION + + message.data = {APPROVAL_ACTION_KEY: COMMENT_ACTION} + assert ApprovalWidget.get_action(message) == COMMENT_ACTION + + message.data = {} + with pytest.raises(RuntimeError): + ApprovalWidget.get_action(message) diff --git a/tests/widgets/test_async_job.py b/tests/widgets/test_async_job.py new file mode 100644 index 00000000..805eb559 --- /dev/null +++ b/tests/widgets/test_async_job.py @@ -0,0 +1,96 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.async_job import ( + ASYNC_JOB_ACTION_KEY, + ASYNC_JOB_ACTION_REFRESH, + ASYNC_JOB_ACTION_RETRY, + ASYNC_JOB_FAILED, + ASYNC_JOB_QUEUED, + ASYNC_JOB_STATUS_KEY, + AsyncJobWidget, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__async_job_widget__queued( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = AsyncJobWidget( + job_id="job-1", + status=ASYNC_JOB_QUEUED, + message=message, + bot=bot, + command="/job", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Обновить", "Отменить"] + + +@pytest.mark.asyncio +async def test__async_job_widget__failed_and_done( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = AsyncJobWidget( + job_id="job-2", + status=ASYNC_JOB_FAILED, + details="Ошибка", + message=message, + bot=bot, + command="/job", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Ошибка" in sent_message.body + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Повторить", "Обновить"] + + widget = AsyncJobWidget( + job_id="job-3", + status="done", + message=message, + bot=bot, + command="/job", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Обновить"] + + +def test__async_job_widget__getters( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_REFRESH} + assert AsyncJobWidget.get_action(message) == ASYNC_JOB_ACTION_REFRESH + + message.data = {ASYNC_JOB_ACTION_KEY: ASYNC_JOB_ACTION_RETRY} + assert AsyncJobWidget.get_action(message) == ASYNC_JOB_ACTION_RETRY + + message.data = {} + with pytest.raises(RuntimeError): + AsyncJobWidget.get_action(message) + + message.metadata = {ASYNC_JOB_STATUS_KEY: "done"} + assert AsyncJobWidget.get_status(message) == "done" + + message.metadata = {} + with pytest.raises(RuntimeError): + AsyncJobWidget.get_status(message) diff --git a/tests/widgets/test_base.py b/tests/widgets/test_base.py new file mode 100644 index 00000000..7a749f1b --- /dev/null +++ b/tests/widgets/test_base.py @@ -0,0 +1,216 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.missing import Undefined +from pybotx.widgets.base import ( + PYBOTX_WIDGET_FLAG, + PYBOTX_WIDGET_RESULT_MODE_KEY, + WIDGET_RESULT_MODE_EDIT, + WIDGET_RESULT_MODE_MESSAGE, + Widget, + _normalize_result_mode, + _parse_result_mode_from_argument, + outgoing_markup, +) +from pybotx.widgets.markup import MessageMarkup + + +class _DummyWidget(Widget): + def add_markup(self) -> None: + self.widget_message.body = "dummy" + self.widget_bubbles.add_button(command=self.command, label="dummy") + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + answer_message=AsyncMock(return_value=uuid4()), + ) + + +@pytest.mark.asyncio +async def test__widget__display_send_message( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + await widget.display() + + assert bot.send.await_count == 1 + assert bot.edit_message.await_count == 0 + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body == "dummy" + assert sent_message.metadata[PYBOTX_WIDGET_FLAG] == 1 + + +@pytest.mark.asyncio +async def test__widget__display_edit_message( + incoming_message_factory: Any, +) -> None: + source_sync_id = uuid4() + message = incoming_message_factory() + message.metadata[PYBOTX_WIDGET_FLAG] = 1 + message.source_sync_id = source_sync_id + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + await widget.display() + + assert bot.send.await_count == 0 + assert bot.edit_message.await_count == 1 + assert bot.edit_message.await_args.kwargs["sync_id"] == source_sync_id + + +def test__widget__add_additional_markup_merge( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + additional_markup = MessageMarkup() + additional_markup.add_bubble(command="/extra", label="Extra") + additional_markup.add_keyboard(command="/extra", label="Extra keyboard") + + widget = _DummyWidget( + message=message, + bot=bot, + command="/dummy", + additional_markup=additional_markup, + ) + widget.add_markup() + widget.add_additional_markup() + + rows = [[button.label for button in row] for row in widget.widget_bubbles] + keyboard_rows = [ + [button.label for button in row] for row in widget.widget_keyboard + ] + + assert rows == [["dummy"], ["Extra"]] + assert keyboard_rows == [["Extra keyboard"]] + + +def test__widget__build_widget_metadata_from_undefined( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {"foo": "bar"} + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + metadata = widget._build_widget_metadata(Undefined) + + assert metadata == {"foo": "bar", PYBOTX_WIDGET_FLAG: 1} + + +def test__widget__outgoing_markup_for_undefined( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + widget.widget_message.bubbles = Undefined + widget.widget_message.keyboard = Undefined + + markup = outgoing_markup(widget.widget_message) + + assert list(markup.bubbles) == [] + assert list(markup.keyboard) == [] + assert list(widget.widget_keyboard) == [] + + +def test__widget__widget_metadata_for_undefined( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + widget.widget_message.metadata = Undefined + + assert widget.widget_metadata == {} + + +@pytest.mark.asyncio +async def test__widget__send_result_edit_mode( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {PYBOTX_WIDGET_FLAG: 1} + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + await widget.send_result("result") + + assert bot.edit_message.await_count == 1 + assert bot.send.await_count == 0 + + +@pytest.mark.asyncio +async def test__widget__send_result_message_mode( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/dummy mode=message") + message.metadata = {PYBOTX_WIDGET_FLAG: 1} + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + await widget.send_result("result") + + assert bot.send.await_count == 0 + assert bot.edit_message.await_count == 0 + assert bot.answer_message.await_count == 1 + + +def test__widget__result_mode_resolved_from_metadata( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {PYBOTX_WIDGET_RESULT_MODE_KEY: WIDGET_RESULT_MODE_MESSAGE} + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + assert widget.widget_metadata[PYBOTX_WIDGET_RESULT_MODE_KEY] == WIDGET_RESULT_MODE_MESSAGE + + +def test__widget__result_mode_default( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + assert widget.result_mode == WIDGET_RESULT_MODE_EDIT + + +def test__widget__result_mode_explicit_param_wins( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/dummy mode=edit") + message.metadata = {PYBOTX_WIDGET_RESULT_MODE_KEY: WIDGET_RESULT_MODE_EDIT} + bot = _build_bot_mock() + + widget = _DummyWidget( + message=message, + bot=bot, + command="/dummy", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +def test__result_mode_parsing__supports_aliases_and_plain_tokens() -> None: + assert _normalize_result_mode(" INLINE ") == WIDGET_RESULT_MODE_EDIT + assert _parse_result_mode_from_argument("reply remaining") == WIDGET_RESULT_MODE_MESSAGE diff --git a/tests/widgets/test_calendar.py b/tests/widgets/test_calendar.py new file mode 100644 index 00000000..ad0a89ba --- /dev/null +++ b/tests/widgets/test_calendar.py @@ -0,0 +1,253 @@ +from datetime import date +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.base import PYBOTX_WIDGET_FLAG +from pybotx.widgets.calendar import ( + MONTH_TO_DISPLAY_KEY, + SELECTED_DATE_KEY, + CalendarWidget, + _parse_date, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__calendar_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = CalendarWidget( + start_date=date(2026, 1, 1), + end_date=date(2026, 1, 31), + include_past=True, + message=message, + bot=bot, + command="/calendar", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + + assert sent_message.body == "Выберите дату" + assert "Пн" in labels + assert any(month in labels for month in CalendarWidget.MONTHS.values()) + + +def test__calendar_widget__invalid_dates( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + with pytest.raises(ValueError): + CalendarWidget( + start_date=date(2026, 2, 1), + end_date=date(2026, 1, 1), + message=message, + bot=bot, + command="/calendar", + ) + + +def test__calendar_widget__get_current_date_from_navigation( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/calendar ⬅️") + message.data = {MONTH_TO_DISPLAY_KEY: "2026-05-01"} + bot = _build_bot_mock() + + widget = CalendarWidget( + start_date=date(2026, 1, 1), + end_date=date(2026, 12, 31), + message=message, + bot=bot, + command="/calendar", + ) + + assert widget.current_date == date(2026, 5, 1) + + +def test__calendar_widget__prev_next_helpers( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = CalendarWidget( + include_past=True, + message=message, + bot=bot, + command="/calendar", + ) + widget.current_date = date(2026, 6, 1) + + assert widget.get_prev_and_next_year() == (date(2025, 1, 1), date(2027, 1, 1)) + assert widget.get_prev_and_next_month() == (date(2026, 5, 1), date(2026, 7, 1)) + + +@pytest.mark.asyncio +async def test__calendar_widget__get_value_update( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_DATE_KEY: "2026-01-15"} + message.metadata = { + PYBOTX_WIDGET_FLAG: 1, + MONTH_TO_DISPLAY_KEY: "2026-01-01", + } + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + selected_date = await CalendarWidget.get_value(message, bot) + + assert selected_date == date(2026, 1, 15) + assert bot.edit_message.await_count == 1 + assert SELECTED_DATE_KEY not in message.data + assert MONTH_TO_DISPLAY_KEY not in message.metadata + + +@pytest.mark.asyncio +async def test__calendar_widget__get_value_without_feedback( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_DATE_KEY: "2026-01-15"} + message.metadata = { + PYBOTX_WIDGET_FLAG: 1, + MONTH_TO_DISPLAY_KEY: "2026-01-01", + } + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + selected_date = await CalendarWidget.get_value( + message, + bot, + send_feedback=False, + ) + + assert selected_date == date(2026, 1, 15) + assert bot.edit_message.await_count == 0 + assert bot.send.await_count == 0 + assert SELECTED_DATE_KEY not in message.data + assert MONTH_TO_DISPLAY_KEY not in message.metadata + + +@pytest.mark.asyncio +async def test__calendar_widget__get_value_error( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + with pytest.raises(RuntimeError): + await CalendarWidget.get_value(message, bot) + + +def test__calendar_widget__parse_date() -> None: + assert _parse_date(date(2026, 1, 1)) == date(2026, 1, 1) + assert _parse_date("2026-01-15") == date(2026, 1, 15) + assert _parse_date("2026-01-15T10:00:00") == date(2026, 1, 15) + assert _parse_date("bad-date") is None + assert _parse_date(42) is None + + +def test__calendar_widget__navigation_with_invalid_month_value( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/calendar ⬅️") + message.data = {MONTH_TO_DISPLAY_KEY: "bad-date"} + bot = _build_bot_mock() + + widget = CalendarWidget( + message=message, + bot=bot, + command="/calendar", + ) + + assert widget.current_date == date.today().replace(day=1) + + +def test__calendar_widget__month_boundaries( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = CalendarWidget(message=message, bot=bot, command="/calendar") + + widget.current_date = date(2026, 1, 1) + assert widget.get_prev_and_next_month() == (date(2025, 12, 1), date(2026, 2, 1)) + + widget.current_date = date(2026, 12, 1) + assert widget.get_prev_and_next_month() == (date(2026, 11, 1), date(2027, 1, 1)) + + +def test__calendar_widget__year_and_month_arrow_visibility( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = CalendarWidget( + start_date=date(2026, 2, 1), + end_date=date(2027, 2, 1), + include_past=False, + message=message, + bot=bot, + command="/calendar", + ) + widget.current_date = date(2026, 2, 1) + widget.add_year_bubbles() + widget.add_month_bubbles() + + labels = [button.label for row in widget.widget_bubbles for button in row] + assert labels.count(" ") >= 2 + assert "➡️" in labels + + upper_limit_widget = CalendarWidget( + start_date=date(2026, 2, 1), + end_date=date(2026, 2, 28), + include_past=False, + message=message, + bot=bot, + command="/calendar", + ) + upper_limit_widget.current_date = date(2026, 2, 1) + upper_limit_widget.add_month_bubbles() + + month_labels = [ + button.label for row in upper_limit_widget.widget_bubbles for button in row + ] + assert month_labels[-1] == " " + + +def test__calendar_widget__day_bubbles_with_visible_days( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = CalendarWidget( + start_date=date(2026, 2, 1), + end_date=date(2026, 2, 28), + include_past=True, + message=message, + bot=bot, + command="/calendar", + ) + widget.current_date = date(2026, 2, 1) + widget.add_day_bubbles() + + labels = [button.label for row in widget.widget_bubbles for button in row] + assert "1" in labels diff --git a/tests/widgets/test_carousel.py b/tests/widgets/test_carousel.py new file mode 100644 index 00000000..efbbce3e --- /dev/null +++ b/tests/widgets/test_carousel.py @@ -0,0 +1,355 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.base import PYBOTX_WIDGET_FLAG +from pybotx.widgets.carousel import ( + LEFT_PRESSED, + MESSAGE_LABEL_KEY, + RIGHT_PRESSED, + SELECTED_VALUE_KEY, + SELECTED_VALUE_LABEL_KEY, + START_FROM_KEY, + CarouselWidget, + _to_int, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__carousel_widget__display_inline( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = CarouselWidget( + widget_content=["a", "b", "c", "d"], + label="Carousel", + start_from=0, + displayed_content_count=2, + loop=False, + inline=True, + message=message, + bot=bot, + command="/carousel", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["a", "b", "➡️"] + + +@pytest.mark.asyncio +async def test__carousel_widget__display_newline( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_VALUE_KEY: RIGHT_PRESSED, START_FROM_KEY: 0} + bot = _build_bot_mock() + + widget = CarouselWidget( + widget_content=[["a", "b"], "c", "d"], + label="Carousel", + displayed_content_count=2, + inline=False, + loop=False, + message=message, + bot=bot, + command="/carousel", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [[button.label for button in row] for row in sent_message.bubbles] + assert labels == [["d"], ["⬅️"]] + + +@pytest.mark.asyncio +async def test__carousel_widget__get_value_selected( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.source_sync_id = uuid4() + message.metadata = {PYBOTX_WIDGET_FLAG: 1} + message.data = { + SELECTED_VALUE_KEY: "value", + MESSAGE_LABEL_KEY: "Label:", + SELECTED_VALUE_LABEL_KEY: "{label} {selected_val}", + START_FROM_KEY: 1, + } + bot = _build_bot_mock() + + selected_value = await CarouselWidget.get_value(message, bot) + + assert selected_value == "value" + assert bot.edit_message.await_count == 1 + assert SELECTED_VALUE_KEY not in message.data + + +@pytest.mark.asyncio +async def test__carousel_widget__get_value_selected_without_feedback( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.source_sync_id = uuid4() + message.metadata = {PYBOTX_WIDGET_FLAG: 1} + message.data = { + SELECTED_VALUE_KEY: "value", + MESSAGE_LABEL_KEY: "Label:", + SELECTED_VALUE_LABEL_KEY: "{label} {selected_val}", + START_FROM_KEY: 1, + } + bot = _build_bot_mock() + + selected_value = await CarouselWidget.get_value( + message, + bot, + send_feedback=False, + ) + + assert selected_value == "value" + assert bot.edit_message.await_count == 0 + assert bot.send.await_count == 0 + assert SELECTED_VALUE_KEY not in message.data + + +@pytest.mark.asyncio +async def test__carousel_widget__get_value_empty( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_VALUE_KEY: LEFT_PRESSED} + bot = _build_bot_mock() + + selected_value = await CarouselWidget.get_value(message, bot) + + assert selected_value is None + + +def test__carousel_widget__helpers( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + assert _to_int(10, 1) == 10 + assert _to_int("7", 1) == 7 + assert _to_int("x", 1) == 1 + + widget = CarouselWidget( + widget_content=[], + label="Carousel", + message=message, + bot=bot, + command="/carousel", + ) + assert widget.start_from == 0 + + +def test__carousel_widget__visibility_and_labels( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = CarouselWidget( + widget_content=["a", "b", "c"], + label="Carousel", + displayed_content_count=1, + show_numbers=True, + loop=False, + inline=False, + message=message, + bot=bot, + command="/carousel", + ) + widget._start_from = 5 + assert widget.start_from == 2 + assert widget.left_btn_label == "⬅️ (2-2)" + assert widget.right_btn_label == "➡️ (4-3)" + + widget.loop = True + assert list(widget.displayed_content) == ["c"] + assert widget.get_left_and_right_button_visibility() == (True, True) + + short_widget = CarouselWidget( + widget_content=["x"], + label="Short", + displayed_content_count=2, + loop=False, + inline=True, + message=message, + bot=bot, + command="/carousel", + ) + assert short_widget.get_left_and_right_button_visibility() == (False, False) + + wide_widget = CarouselWidget( + widget_content=["a", "b", "c", "d", "e", "f", "g"], + label="Wide", + displayed_content_count=1, + show_numbers=True, + loop=False, + inline=False, + message=message, + bot=bot, + command="/carousel", + ) + wide_widget._start_from = 1 + assert wide_widget.right_btn_label == "➡️ (3-3)" + + +@pytest.mark.asyncio +async def test__carousel_widget__inline_all_buttons_visible( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_VALUE_KEY: LEFT_PRESSED, START_FROM_KEY: 1} + bot = _build_bot_mock() + + widget = CarouselWidget( + widget_content=["a", "b", "c", "d"], + label="Carousel", + displayed_content_count=2, + loop=False, + inline=True, + message=message, + bot=bot, + command="/carousel", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["⬅️", "d"] + + +@pytest.mark.asyncio +async def test__carousel_widget__newline_with_row_and_right_arrow( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = CarouselWidget( + widget_content=[["a", "b"], "c", "d", "e"], + label="Carousel", + displayed_content_count=2, + loop=False, + inline=False, + message=message, + bot=bot, + command="/carousel", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [[button.label for button in row] for row in sent_message.bubbles] + assert labels == [["a", "b"], ["c"], ["➡️"]] + + +def test__carousel_widget__invalid_selected_value_label( + incoming_message_factory: Any, +) -> None: + class _BadCarousel(CarouselWidget): + SELECTED_VALUE_LABEL = "{label}" + + message = incoming_message_factory() + bot = _build_bot_mock() + + with pytest.raises(ValueError): + _BadCarousel( + widget_content=["a"], + label="Carousel", + message=message, + bot=bot, + command="/carousel", + ) + + +def test__carousel_widget__validation_errors( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a"], + label="Carousel", + start_from=2, + message=message, + bot=bot, + command="/carousel", + ) + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a"], + label="Carousel", + displayed_content_count=0, + message=message, + bot=bot, + command="/carousel", + ) + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a", "b"], + label="Carousel", + show_numbers=True, + loop=True, + inline=False, + message=message, + bot=bot, + command="/carousel", + ) + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a", "b"], + label="Carousel", + show_numbers=True, + loop=False, + inline=True, + message=message, + bot=bot, + command="/carousel", + ) + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a", "b"], + label="Carousel", + show_numbers=True, + loop=False, + inline=False, + control_labels=("{bad}", "{}-{}"), + message=message, + bot=bot, + command="/carousel", + ) + + with pytest.raises(ValueError): + CarouselWidget( + widget_content=["a", "b"], + label="Carousel", + show_numbers=True, + loop=False, + inline=False, + control_labels=("{}-{}", "{}"), + message=message, + bot=bot, + command="/carousel", + ) diff --git a/tests/widgets/test_checklist.py b/tests/widgets/test_checklist.py new file mode 100644 index 00000000..4ad0c22e --- /dev/null +++ b/tests/widgets/test_checklist.py @@ -0,0 +1,94 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.checklist import CHECKED_ITEMS_KEY, CheckListWidget, SELECTED_ITEM_KEY + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__checklist_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {CHECKED_ITEMS_KEY: ["b"]} + bot = _build_bot_mock() + + widget = CheckListWidget( + widget_content=["a", ["b", "c"]], + label="Checklist", + message=message, + bot=bot, + command="/checklist", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [[button.label for button in row] for row in sent_message.bubbles] + + assert sent_message.body == "Checklist" + assert labels == [["☐ a"], ["☑ b", "☐ c"]] + + +@pytest.mark.asyncio +async def test__checklist_widget__toggle_selected_item( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_ITEM_KEY: "x"} + message.metadata = {CHECKED_ITEMS_KEY: ["x", "y"]} + bot = _build_bot_mock() + + widget = CheckListWidget( + widget_content=["x", "y"], + label="Checklist", + message=message, + bot=bot, + command="/checklist", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[CHECKED_ITEMS_KEY] == ["y"] + + +@pytest.mark.asyncio +async def test__checklist_widget__toggle_add_item( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_ITEM_KEY: "z"} + message.metadata = {CHECKED_ITEMS_KEY: ["x"]} + bot = _build_bot_mock() + + widget = CheckListWidget( + widget_content=["x", "z"], + label="Checklist", + message=message, + bot=bot, + command="/checklist", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[CHECKED_ITEMS_KEY] == ["x", "z"] + + +def test__checklist_widget__helpers( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECTED_ITEM_KEY: "test"} + message.metadata = {CHECKED_ITEMS_KEY: ["a", "b"]} + + assert CheckListWidget.get_value(message) == "test" + assert CheckListWidget.get_checked_items(message) == ["a", "b"] diff --git a/tests/widgets/test_checktable.py b/tests/widgets/test_checktable.py new file mode 100644 index 00000000..f5eb52e4 --- /dev/null +++ b/tests/widgets/test_checktable.py @@ -0,0 +1,86 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.checktable import CheckboxContent, ChecktableWidget +from pybotx.widgets.undefined import undefined + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +def test__checkbox_content__validation_error() -> None: + with pytest.raises(ValueError): + CheckboxContent[str]( + label="Field", + command="/command", + checkbox_value="unknown", + mapping={"known": "Known"}, + ) + + +@pytest.mark.asyncio +async def test__checktable_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + checkboxes: list[CheckboxContent[Any]] = [ + CheckboxContent[str]( + label="Undefined", + command="/fill", + checkbox_value=undefined, + ), + CheckboxContent[str]( + label="None", + command="/fill", + checkbox_value=None, + ), + CheckboxContent[str]( + label="Mapped", + command="/select", + checkbox_value="k1", + mapping={"k1": "Value 1"}, + ), + CheckboxContent[str]( + label="Mapped undefined", + command="/select", + checkbox_value=undefined, + mapping={"k1": "Value 1"}, + ), + CheckboxContent[int]( + label="Numeric", + command="/fill", + checkbox_value=7, + ), + ] + + widget = ChecktableWidget( + checkboxes=checkboxes, + label="Checktable", + uncheck_command="/uncheck", + message=message, + bot=bot, + command="/checktable", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + + assert sent_message.body == "Checktable" + assert rows == [ + ["☐ Undefined", "Ввести"], + ["☑ None", "[Пусто]"], + ["☑ Mapped", "Value 1"], + ["☐ Mapped undefined", "Выбрать"], + ["☑ Numeric", "7"], + ] diff --git a/tests/widgets/test_confirm.py b/tests/widgets/test_confirm.py new file mode 100644 index 00000000..4ed221db --- /dev/null +++ b/tests/widgets/test_confirm.py @@ -0,0 +1,56 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.confirm import ( + CANCEL_ACTION, + CONFIRM_ACTION, + CONFIRM_ACTION_KEY, + ConfirmWidget, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__confirm_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = ConfirmWidget( + label="Подтвердите действие", + message=message, + bot=bot, + command="/confirm", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + assert rows == [["Подтвердить", "Отмена"]] + + +def test__confirm_widget__actions( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {CONFIRM_ACTION_KEY: CONFIRM_ACTION} + + assert ConfirmWidget.get_action(message) == CONFIRM_ACTION + assert ConfirmWidget.is_confirmed(message) + assert not ConfirmWidget.is_cancelled(message) + + message.data = {CONFIRM_ACTION_KEY: CANCEL_ACTION} + assert ConfirmWidget.get_action(message) == CANCEL_ACTION + assert ConfirmWidget.is_cancelled(message) + + message.data = {} + with pytest.raises(RuntimeError): + ConfirmWidget.get_action(message) diff --git a/tests/widgets/test_date_range.py b/tests/widgets/test_date_range.py new file mode 100644 index 00000000..61c1527e --- /dev/null +++ b/tests/widgets/test_date_range.py @@ -0,0 +1,123 @@ +from datetime import date +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.date_range import ( + DATE_RANGE_ACTION_KEY, + DATE_RANGE_ACTION_RESET, + DATE_RANGE_CURSOR_KEY, + DATE_RANGE_END_KEY, + DATE_RANGE_SELECTED_DATE_KEY, + DATE_RANGE_START_KEY, + DateRangeWidget, + _parse_iso_date, + _serialize_date, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +def test__date_range_widget__helpers() -> None: + assert _parse_iso_date(date(2026, 1, 1)) == date(2026, 1, 1) + assert _parse_iso_date("2026-01-01") == date(2026, 1, 1) + assert _parse_iso_date("bad") is None + assert _parse_iso_date(1) is None + assert _serialize_date(date(2026, 1, 1)) == "2026-01-01" + assert _serialize_date(None) is None + + +@pytest.mark.asyncio +async def test__date_range_widget__start_cursor( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = DateRangeWidget(message=message, bot=bot, command="/date-range") + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Сегодня", "Завтра"] + + +@pytest.mark.asyncio +async def test__date_range_widget__select_start_then_end( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-02-10"} + bot = _build_bot_mock() + + widget = DateRangeWidget(message=message, bot=bot, command="/date-range") + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[DATE_RANGE_CURSOR_KEY] == "end" + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["+1 дн.", "+7 дн.", "+30 дн.", "Сбросить"] + + message.metadata = { + DATE_RANGE_START_KEY: "2026-02-10", + DATE_RANGE_CURSOR_KEY: "end", + } + message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-02-08"} + widget = DateRangeWidget(message=message, bot=bot, command="/date-range") + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[DATE_RANGE_START_KEY] == "2026-02-08" + assert sent_message.metadata[DATE_RANGE_END_KEY] == "2026-02-10" + assert sent_message.metadata[DATE_RANGE_CURSOR_KEY] == "done" + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Сбросить"] + + message.metadata = { + DATE_RANGE_START_KEY: "2026-02-08", + DATE_RANGE_CURSOR_KEY: "end", + } + message.data = {DATE_RANGE_SELECTED_DATE_KEY: "2026-02-12"} + widget = DateRangeWidget(message=message, bot=bot, command="/date-range") + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[DATE_RANGE_START_KEY] == "2026-02-08" + assert sent_message.metadata[DATE_RANGE_END_KEY] == "2026-02-12" + + +@pytest.mark.asyncio +async def test__date_range_widget__reset_action( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = { + DATE_RANGE_START_KEY: "2026-01-01", + DATE_RANGE_END_KEY: "2026-01-10", + DATE_RANGE_CURSOR_KEY: "done", + } + message.data = {DATE_RANGE_ACTION_KEY: DATE_RANGE_ACTION_RESET} + bot = _build_bot_mock() + + widget = DateRangeWidget(message=message, bot=bot, command="/date-range") + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[DATE_RANGE_START_KEY] is None + assert sent_message.metadata[DATE_RANGE_CURSOR_KEY] == "start" + + +def test__date_range_widget__get_value( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = { + DATE_RANGE_START_KEY: "2026-01-01", + DATE_RANGE_END_KEY: "2026-01-10", + } + assert DateRangeWidget.get_value(message) == (date(2026, 1, 1), date(2026, 1, 10)) + + message.metadata = {DATE_RANGE_START_KEY: "2026-01-01"} + with pytest.raises(RuntimeError): + DateRangeWidget.get_value(message) diff --git a/tests/widgets/test_factory.py b/tests/widgets/test_factory.py new file mode 100644 index 00000000..3bc15a7e --- /dev/null +++ b/tests/widgets/test_factory.py @@ -0,0 +1,681 @@ +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock +from uuid import uuid4 +from datetime import date + +import pytest + +from pybotx.widgets import ( + WidgetContext, + WidgetDefaults, + WidgetFactory, +) +from pybotx.widgets.async_job import ASYNC_JOB_FAILED, ASYNC_JOB_QUEUED +from pybotx.widgets.base import WIDGET_RESULT_MODE_MESSAGE +from pybotx.widgets.checklist import CHECKED_ITEMS_KEY, SELECTED_ITEM_KEY +from pybotx.widgets.checktable import CheckboxContent +from pybotx.widgets.date_range import DATE_RANGE_CURSOR_KEY +from pybotx.widgets.file_batch import ( + FILE_BATCH_FILES_KEY, + FILE_BATCH_PAGE_KEY, + BatchFileItem, +) +from pybotx.widgets.pagination import MESSAGE_IDS_KEY +from pybotx.widgets.form_wizard import FORM_VALUES_KEY, FormWizardStep +from pybotx.widgets.markup import MessageMarkup +from pybotx.widgets.messages_pager import MessagesPagerWidget +from pybotx.widgets.multi_select import MULTI_SELECTED_VALUES_KEY, MULTI_SELECT_VALUE_KEY +from pybotx.widgets.range import ELEMENTS_KEY +from pybotx.widgets.search_select import SEARCH_PAGE_KEY, SEARCH_QUERY_KEY +from pybotx.widgets.select import SelectOption +from pybotx.widgets.table import TABLE_PAGE_KEY, TABLE_QUERY_KEY +from pybotx.widgets.undefined import undefined + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + answer_message=AsyncMock(return_value=uuid4()), + ) + + +def test__widget_context__exports_base_widget_kwargs( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + markup = MessageMarkup() + context = WidgetContext( + message=message, + bot=bot, + command="/confirm", + additional_markup=markup, + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + + assert context.as_widget_kwargs() == { + "message": message, + "bot": bot, + "command": "/confirm", + "additional_markup": markup, + "result_mode": WIDGET_RESULT_MODE_MESSAGE, + } + + minimal_context = WidgetContext(message=message, bot=bot, command="/minimal") + assert minimal_context.as_widget_kwargs() == { + "message": message, + "bot": bot, + "command": "/minimal", + } + + override_markup = MessageMarkup() + factory = WidgetFactory(context=minimal_context) + created = factory.confirm(label="Confirm", additional_markup=override_markup) + assert created.additional_markup is override_markup + + +@pytest.mark.asyncio +async def test__widget_factory__builds_confirm_widget_with_defaults( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/confirm", + ), + ) + + widget = factory.confirm(label="Подтвердить действие") + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + assert rows == [["Подтвердить", "Отмена"]] + + +@pytest.mark.asyncio +async def test__widget_factory__applies_custom_defaults_to_approval_widget( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + defaults = WidgetDefaults( + approve_label="Approve", + reject_label="Reject", + comment_label="Comment", + ) + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/approval", + ), + defaults=defaults, + ) + + widget = factory.approval(label="Review request") + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + assert rows == [["Approve", "Reject"], ["Comment"]] + + +@pytest.mark.asyncio +async def test__widget_factory__select_supports_context_and_per_call_overrides( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.data = {"select_value": "b"} + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Context keyboard") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/select", + additional_markup=context_markup, + ), + ) + + widget = factory.select( + options=[ + "a", + SelectOption(value="b", label="Bee"), + ], + label="Выберите", + selected_prefix="> ", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + bubble_rows = [[button.label for button in row] for row in sent_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert bubble_rows == [["a"], ["> Bee"]] + assert keyboard_rows == [["Context keyboard"]] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__search_select_uses_defaults_and_context( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.data = {SEARCH_QUERY_KEY: "ap", SEARCH_PAGE_KEY: 1} + bot = _build_bot_mock() + defaults = WidgetDefaults( + selected_prefix="> ", + search_empty_label="Empty", + ) + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/search", + ), + defaults=defaults, + ) + + widget = factory.search_select( + options=["apple", "banana", "apricot"], + label="Поиск", + page_size=1, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["apricot", "Назад"] + assert sent_message.body == "Поиск\nПоиск: ap" + + +@pytest.mark.asyncio +async def test__widget_factory__multi_select_supports_context_and_overrides( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.metadata = {MULTI_SELECTED_VALUES_KEY: ["a"]} + message.data = {MULTI_SELECT_VALUE_KEY: "b"} + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Done") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/multi", + additional_markup=context_markup, + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ), + ) + + widget = factory.multi_select( + options=["a", "b", "c"], + label="Выберите", + max_selected=3, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + bubble_rows = [[button.label for button in row] for row in sent_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert sent_message.metadata[MULTI_SELECTED_VALUES_KEY] == ["a", "b"] + assert bubble_rows == [["☑ a"], ["☑ b"], ["☐ c"]] + assert keyboard_rows == [["Done"]] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__form_wizard_uses_context_and_result_mode( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)(body="/form Alice") + message.metadata = {FORM_VALUES_KEY: {"name": "Alice"}} + bot = _build_bot_mock() + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/form", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ), + ) + + widget = factory.form_wizard( + steps=[ + FormWizardStep(field="name", label="Введите имя"), + FormWizardStep(field="email", label="Введите email"), + ], + label="Форма", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert "Шаг 1/2" in sent_message.body + assert labels == ["Далее", "Отмена", "Использовать текст сообщения"] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__table_supports_defaults_and_context( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.data = { + TABLE_QUERY_KEY: "a", + TABLE_PAGE_KEY: 0, + } + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Refresh") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/table", + additional_markup=context_markup, + ), + ) + + widget = factory.table( + rows=[ + {"name": "Alice", "role": "admin"}, + {"name": "Charlie", "role": "analyst"}, + ], + columns=["name", "role"], + label="Пользователи", + page_size=1, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert "Пользователи" in sent_message.body + assert "Фильтр: a" in sent_message.body + assert labels == ["Вперед", "Сорт: name", "Сорт: role", "Сбросить фильтр"] + assert keyboard_rows == [["Refresh"]] + + +@pytest.mark.asyncio +async def test__widget_factory__calendar_supports_context_and_overrides( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/calendar", + ), + ) + + widget = factory.calendar( + start_date=date(2026, 1, 1), + end_date=date(2026, 1, 31), + include_past=True, + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert sent_message.body == "Выберите дату" + assert "Пн" in labels + assert any(month in labels for month in widget.MONTHS.values()) + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__range_supports_context_and_markup( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/range", + additional_markup=context_markup, + ), + ) + + widget = factory.range( + elements=["one", "two", "three"], + label_template="Item: {element}", + forward_label="Next", + backward_label="Prev", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert sent_message.body == "Item: one" + assert sent_message.metadata[ELEMENTS_KEY] == ["one", "two", "three"] + assert rows == [["Next"]] + assert keyboard_rows == [["Close"]] + + +@pytest.mark.asyncio +async def test__widget_factory__carousel_supports_context_and_result_mode( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/carousel", + additional_markup=context_markup, + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ), + ) + + widget = factory.carousel( + widget_content=["a", "b", "c", "d"], + label="Carousel", + displayed_content_count=2, + loop=False, + inline=True, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert labels == ["a", "b", "➡️"] + assert keyboard_rows == [["Close"]] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__pagination_supports_context_and_markup( + incoming_message_factory: object, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/pagination", + additional_markup=context_markup, + ), + ) + + widget = factory.pagination( + widget_content=["1", "2", "3"], + paginate_by=2, + delay_between_messages=0.0, + ) + await widget.display() + + assert bot.send.await_count == 2 + last_message = bot.send.await_args_list[-1].kwargs["message"] + bubble_rows = [[button.label for button in row] for row in last_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in last_message.keyboard] + assert last_message.body == "2" + assert bubble_rows == [["➡️ Вперёд к [3]"]] + assert keyboard_rows == [["Close"]] + assert MESSAGE_IDS_KEY in last_message.metadata + + +@pytest.mark.asyncio +async def test__widget_factory__messages_pager_supports_context_and_template( + incoming_message_factory: object, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/pager", + additional_markup=context_markup, + ), + ) + + widget = factory.messages_pager( + elements=["x", "y", "z"], + page_size=2, + item_template="Elem #{index}/{total}: {element}", + delay_between_messages=0.0, + ) + + assert isinstance(widget, MessagesPagerWidget) + await widget.display() + + last_message = bot.send.await_args_list[-1].kwargs["message"] + keyboard_rows = [[button.label for button in row] for row in last_message.keyboard] + assert last_message.body == "Elem #2/3: y" + assert keyboard_rows == [["Close"]] + assert MESSAGE_IDS_KEY in last_message.metadata + + +@pytest.mark.asyncio +async def test__widget_factory__date_range_supports_context_and_result_mode( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/date-range", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ), + ) + + widget = factory.date_range(label="Период") + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert "Период" in sent_message.body + assert labels == ["Сегодня", "Завтра"] + assert sent_message.metadata[DATE_RANGE_CURSOR_KEY] == "start" + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__async_job_supports_context_and_markup( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/job", + additional_markup=context_markup, + ), + ) + + widget = factory.async_job( + job_id="job-1", + status=ASYNC_JOB_QUEUED, + details="Подготовка", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert "Задача job-1" in sent_message.body + assert labels == ["Обновить", "Отменить"] + assert keyboard_rows == [["Close"]] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__async_job_failed_state_is_preserved( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/job", + ), + ) + + widget = factory.async_job( + job_id="job-2", + status=ASYNC_JOB_FAILED, + details="Ошибка", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Повторить", "Обновить"] + + +@pytest.mark.asyncio +async def test__widget_factory__file_batch_supports_context_and_markup( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.data = {FILE_BATCH_PAGE_KEY: 0} + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/batch", + additional_markup=context_markup, + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ), + ) + + widget = factory.file_batch( + files=[ + BatchFileItem(name="a.txt", status="done"), + {"name": "b.txt", "status": "failed", "error": "oops"}, + "c.txt", + ], + page_size=2, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert "Всего: 3" in sent_message.body + assert labels == ["a.txt", "b.txt", "Вперед", "Повторить ошибки"] + assert keyboard_rows == [["Close"]] + assert FILE_BATCH_FILES_KEY in sent_message.metadata + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE + + +@pytest.mark.asyncio +async def test__widget_factory__checklist_supports_context_and_checked_items( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + message.metadata = {CHECKED_ITEMS_KEY: ["b"]} + message.data = {SELECTED_ITEM_KEY: "a"} + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/checklist", + additional_markup=context_markup, + ), + ) + + widget = factory.checklist( + widget_content=["a", ["b", "c"]], + label="Checklist", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert sent_message.metadata[CHECKED_ITEMS_KEY] == ["b", "a"] + assert rows == [["☑ a"], ["☑ b", "☐ c"]] + assert keyboard_rows == [["Close"]] + + +@pytest.mark.asyncio +async def test__widget_factory__checktable_supports_context_and_markup( + incoming_message_factory: object, +) -> None: + message = cast(Any, incoming_message_factory)() + bot = _build_bot_mock() + context_markup = MessageMarkup() + context_markup.add_keyboard(command="/ctx", label="Close") + factory = WidgetFactory( + context=WidgetContext( + message=message, + bot=bot, + command="/checktable", + additional_markup=context_markup, + ), + ) + + widget = factory.checktable( + checkboxes=[ + CheckboxContent[str]( + label="Undefined", + command="/fill", + checkbox_value=undefined, + ), + CheckboxContent[str]( + label="Mapped", + command="/select", + checkbox_value="k1", + mapping={"k1": "Value 1"}, + ), + ], + label="Checktable", + uncheck_command="/uncheck", + result_mode=WIDGET_RESULT_MODE_MESSAGE, + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + keyboard_rows = [[button.label for button in row] for row in sent_message.keyboard] + assert rows == [["☐ Undefined", "Ввести"], ["☑ Mapped", "Value 1"]] + assert keyboard_rows == [["Close"]] + assert widget.result_mode == WIDGET_RESULT_MODE_MESSAGE diff --git a/tests/widgets/test_file_batch.py b/tests/widgets/test_file_batch.py new file mode 100644 index 00000000..7a3ce952 --- /dev/null +++ b/tests/widgets/test_file_batch.py @@ -0,0 +1,132 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.file_batch import ( + FILE_BATCH_ACTION_ITEM, + FILE_BATCH_ACTION_KEY, + FILE_BATCH_ACTION_NEXT, + FILE_BATCH_FILE_NAME_KEY, + FILE_BATCH_FILES_KEY, + FILE_BATCH_PAGE_KEY, + BatchFileItem, + FileBatchWidget, + _normalize_item, + _safe_int, + _status_icon, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +def test__file_batch_widget__helpers() -> None: + assert _safe_int(1) == 1 + assert _safe_int("2") == 2 + assert _safe_int("bad") == 0 + + assert _normalize_item(BatchFileItem(name="a", status="done")) is not None + assert _normalize_item({"name": "b", "status": "failed"}) is not None + assert _normalize_item("c") is not None + assert _normalize_item({"name": "x"}) is None + assert _normalize_item(1) is None + + assert _status_icon("done") == "✅" + assert _status_icon("failed") == "❌" + assert _status_icon("running") == "⏳" + assert _status_icon("unknown") == "•" + + +@pytest.mark.asyncio +async def test__file_batch_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {FILE_BATCH_PAGE_KEY: 0} + bot = _build_bot_mock() + + widget = FileBatchWidget( + files=[ + BatchFileItem(name="a.txt", status="done"), + {"name": "b.txt", "status": "failed", "error": "oops"}, + "c.txt", + ], + page_size=2, + message=message, + bot=bot, + command="/batch", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert "Всего: 3" in sent_message.body + assert "Ошибок: 1" in sent_message.body + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["a.txt", "b.txt", "Вперед", "Повторить ошибки"] + assert FILE_BATCH_FILES_KEY in sent_message.metadata + + +@pytest.mark.asyncio +async def test__file_batch_widget__metadata_source( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = { + FILE_BATCH_FILES_KEY: [ + {"name": "x.txt", "status": "running"}, + {"name": "y.txt", "status": "done"}, + ], + FILE_BATCH_PAGE_KEY: 1, + } + message.data = {FILE_BATCH_PAGE_KEY: 10} + bot = _build_bot_mock() + + widget = FileBatchWidget( + files=None, + page_size=1, + message=message, + bot=bot, + command="/batch", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["y.txt", "Назад"] + + message.metadata = {FILE_BATCH_FILES_KEY: "bad"} + message.data = {} + widget = FileBatchWidget( + files=None, + message=message, + bot=bot, + command="/batch", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Всего: 0" in sent_message.body + + +def test__file_batch_widget__getters( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_NEXT} + assert FileBatchWidget.get_action(message) == FILE_BATCH_ACTION_NEXT + + message.data = { + FILE_BATCH_ACTION_KEY: FILE_BATCH_ACTION_ITEM, + FILE_BATCH_FILE_NAME_KEY: "a.txt", + } + assert FileBatchWidget.get_action(message) == FILE_BATCH_ACTION_ITEM + assert FileBatchWidget.get_selected_file(message) == "a.txt" + + message.data = {} + with pytest.raises(RuntimeError): + FileBatchWidget.get_action(message) + + with pytest.raises(RuntimeError): + FileBatchWidget.get_selected_file(message) diff --git a/tests/widgets/test_form_wizard.py b/tests/widgets/test_form_wizard.py new file mode 100644 index 00000000..012e9aec --- /dev/null +++ b/tests/widgets/test_form_wizard.py @@ -0,0 +1,188 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.form_wizard import ( + FORM_ACTION_BACK, + FORM_ACTION_CANCEL, + FORM_ACTION_KEY, + FORM_ACTION_NEXT, + FORM_ACTION_RESET, + FORM_INPUT_KEY, + FORM_STEP_INDEX_KEY, + FORM_VALUES_KEY, + FormWizardStep, + FormWizardWidget, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +def _steps() -> list[FormWizardStep]: + return [ + FormWizardStep(field="name", label="Введите имя"), + FormWizardStep(field="email", label="Введите email"), + ] + + +@pytest.mark.asyncio +async def test__form_wizard_widget__active_step( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/form John") + bot = _build_bot_mock() + + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert "Шаг 1/2" in sent_message.body + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Далее", "Отмена", "Использовать текст сообщения"] + assert widget.is_active + + +@pytest.mark.asyncio +async def test__form_wizard_widget__next_and_back( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = { + FORM_STEP_INDEX_KEY: 1, + FORM_VALUES_KEY: {"name": "Alice"}, + } + message.data = {FORM_ACTION_KEY: FORM_ACTION_BACK} + bot = _build_bot_mock() + + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Шаг 1/2" in sent_message.body + + message.data = {} + message.metadata = { + FORM_STEP_INDEX_KEY: "1", + FORM_VALUES_KEY: {"name": "Alice"}, + } + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Назад", "Далее", "Отмена", "Использовать текст сообщения"] + + message.data = { + FORM_ACTION_KEY: FORM_ACTION_NEXT, + FORM_INPUT_KEY: "alice@example.com", + } + message.metadata = { + FORM_STEP_INDEX_KEY: 1, + FORM_VALUES_KEY: {"name": "Alice"}, + } + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Форма заполнена." in sent_message.body + assert sent_message.metadata[FORM_VALUES_KEY]["email"] == "alice@example.com" + + +@pytest.mark.asyncio +async def test__form_wizard_widget__cancel_and_reset( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {FORM_ACTION_KEY: FORM_ACTION_CANCEL} + bot = _build_bot_mock() + + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Операция отменена." in sent_message.body + + message.data = {FORM_ACTION_KEY: FORM_ACTION_RESET} + message.metadata = { + FORM_VALUES_KEY: {"name": "Bob"}, + FORM_STEP_INDEX_KEY: 1, + } + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Шаг 1/2" in sent_message.body + assert sent_message.metadata[FORM_VALUES_KEY] == {} + + +def test__form_wizard_widget__get_values( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {FORM_VALUES_KEY: {"a": 1}} + assert FormWizardWidget.get_values(message) == {"a": 1} + + message.metadata = {FORM_VALUES_KEY: "bad"} + with pytest.raises(RuntimeError): + FormWizardWidget.get_values(message) + + +@pytest.mark.asyncio +async def test__form_wizard_widget__input_ignored_for_completed_step( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = { + FORM_STEP_INDEX_KEY: 2, + FORM_VALUES_KEY: {"name": "Alice"}, + } + message.data = {FORM_INPUT_KEY: "should-not-be-used"} + bot = _build_bot_mock() + + widget = FormWizardWidget( + steps=_steps(), + label="Форма", + message=message, + bot=bot, + command="/form", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Форма заполнена." in sent_message.body + assert sent_message.metadata[FORM_VALUES_KEY] == {"name": "Alice"} diff --git a/tests/widgets/test_markup.py b/tests/widgets/test_markup.py new file mode 100644 index 00000000..a7833ac2 --- /dev/null +++ b/tests/widgets/test_markup.py @@ -0,0 +1,50 @@ +from pybotx.widgets.markup import MessageMarkup, clone_markup, merge_message_markup + + +def test__message_markup__add_buttons() -> None: + markup = MessageMarkup() + + markup.add_bubble(command="/bubble", label="Bubble") + markup.add_keyboard(command="/keyboard", label="Keyboard") + + bubble_rows = [[button.label for button in row] for row in markup.bubbles] + keyboard_rows = [[button.label for button in row] for row in markup.keyboard] + + assert bubble_rows == [["Bubble"]] + assert keyboard_rows == [["Keyboard"]] + + +def test__clone_markup__copy_rows() -> None: + markup = MessageMarkup() + markup.add_bubble(command="/bubble-1", label="Bubble 1") + markup.add_bubble(command="/bubble-2", label="Bubble 2", new_row=False) + + cloned = clone_markup(markup.bubbles) + cloned.add_button(command="/bubble-3", label="Bubble 3") + + original_rows = [[button.label for button in row] for row in markup.bubbles] + cloned_rows = [[button.label for button in row] for row in cloned] + + assert original_rows == [["Bubble 1", "Bubble 2"]] + assert cloned_rows == [["Bubble 1", "Bubble 2"], ["Bubble 3"]] + + +def test__merge_message_markup__merge_bubbles_and_keyboard() -> None: + primary = MessageMarkup() + primary.add_bubble(command="/bubble-1", label="Bubble 1") + primary.add_keyboard(command="/keyboard-1", label="Keyboard 1") + + additional = MessageMarkup() + additional.add_bubble(command="/bubble-2", label="Bubble 2") + additional.add_keyboard(command="/keyboard-2", label="Keyboard 2") + + merged = merge_message_markup(primary, additional) + + assert [[button.label for button in row] for row in merged.bubbles] == [ + ["Bubble 1"], + ["Bubble 2"], + ] + assert [[button.label for button in row] for row in merged.keyboard] == [ + ["Keyboard 1"], + ["Keyboard 2"], + ] diff --git a/tests/widgets/test_messages_pager.py b/tests/widgets/test_messages_pager.py new file mode 100644 index 00000000..c02981fc --- /dev/null +++ b/tests/widgets/test_messages_pager.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.messages_pager import MessagesPagerWidget +from pybotx.widgets.pagination import MESSAGE_IDS_KEY + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock( + side_effect=[ + uuid4(), + uuid4(), + uuid4(), + ] + ), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__messages_pager_widget__display( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = MessagesPagerWidget( + elements=["1", "2", "3"], + page_size=2, + message=message, + bot=bot, + command="/widget", + ) + await widget.display() + + last_sent_message = bot.send.await_args_list[-1].kwargs["message"] + assert last_sent_message.body == "Element: 2" + assert MESSAGE_IDS_KEY in last_sent_message.metadata + + +@pytest.mark.asyncio +async def test__messages_pager_widget__display_custom_template( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = MessagesPagerWidget( + elements=["x"], + page_size=1, + item_template="Elem #{index}/{total}: {element}", + message=message, + bot=bot, + command="/widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body == "Elem #1/1: x" diff --git a/tests/widgets/test_module.py b/tests/widgets/test_module.py new file mode 100644 index 00000000..851e6fba --- /dev/null +++ b/tests/widgets/test_module.py @@ -0,0 +1,95 @@ +from pybotx.widgets import ( + ApprovalWidget, + AsyncJobWidget, + BatchFileItem, + build_production_widget_runner_preset, + build_widget_runner_observability_preset, + CalendarWidget, + CarouselWidget, + CheckListWidget, + CheckboxContent, + ChecktableWidget, + ConfirmWidget, + DateRangeWidget, + FileBatchWidget, + FormWizardStep, + FormWizardWidget, + InMemoryWidgetRunnerMetricsCollector, + LoggingWidgetRunnerObserver, + MessageMarkup, + MessagesPagerWidget, + MultiSelectWidget, + NoopWidgetRunnerMetricsCollector, + PaginationWidget, + PrometheusWidgetRunnerMetricsCollector, + RangeWidget, + RunnerHookResult, + SearchSelectWidget, + SelectOption, + SelectWidget, + TableWidget, + Undefined, + WidgetRunner, + WidgetRunnerConfig, + WidgetRunnerFinishedEvent, + WidgetRunnerMetricsSnapshot, + WidgetRunnerObserver, + WidgetRunnerObservabilityPreset, + WidgetRunnerPreset, + WidgetRunnerStartedEvent, + on_action, + on_completed, + on_data_key, + widget_command, + undefined, +) + + +def test__widgets_module__exports() -> None: + assert CalendarWidget is not None + assert CarouselWidget is not None + assert ApprovalWidget is not None + assert AsyncJobWidget is not None + assert BatchFileItem is not None + assert CheckListWidget is not None + assert ChecktableWidget is not None + assert CheckboxContent is not None + assert ConfirmWidget is not None + assert DateRangeWidget is not None + assert FileBatchWidget is not None + assert FormWizardStep is not None + assert FormWizardWidget is not None + assert InMemoryWidgetRunnerMetricsCollector is not None + assert MessageMarkup is not None + assert MessagesPagerWidget is not None + assert MultiSelectWidget is not None + assert NoopWidgetRunnerMetricsCollector is not None + assert PaginationWidget is not None + assert PrometheusWidgetRunnerMetricsCollector is not None + assert RangeWidget is not None + assert LoggingWidgetRunnerObserver is not None + assert RunnerHookResult is not None + assert SearchSelectWidget is not None + assert SelectOption is not None + assert SelectWidget is not None + assert TableWidget is not None + assert WidgetRunner is not None + assert WidgetRunnerConfig is not None + assert WidgetRunnerFinishedEvent is not None + assert WidgetRunnerMetricsSnapshot is not None + assert WidgetRunnerObserver is not None + assert WidgetRunnerObservabilityPreset is not None + assert WidgetRunnerPreset is not None + assert WidgetRunnerStartedEvent is not None + assert build_production_widget_runner_preset is not None + assert build_widget_runner_observability_preset is not None + assert on_action is not None + assert on_completed is not None + assert on_data_key is not None + assert widget_command is not None + assert Undefined is not None + assert undefined is not None + + +def test__widgets_module__undefined_singleton() -> None: + assert Undefined() is undefined diff --git a/tests/widgets/test_multi_select.py b/tests/widgets/test_multi_select.py new file mode 100644 index 00000000..58eba6e6 --- /dev/null +++ b/tests/widgets/test_multi_select.py @@ -0,0 +1,108 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.multi_select import ( + MULTI_SELECTED_VALUES_KEY, + MULTI_SELECT_VALUE_KEY, + MultiSelectWidget, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__multi_select_widget__toggle( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {MULTI_SELECTED_VALUES_KEY: ["a"]} + message.data = {MULTI_SELECT_VALUE_KEY: "b"} + bot = _build_bot_mock() + + widget = MultiSelectWidget( + options=["a", "b"], + label="Выберите", + message=message, + bot=bot, + command="/multi-select", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[MULTI_SELECTED_VALUES_KEY] == ["a", "b"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["☑ a", "☑ b"] + + +@pytest.mark.asyncio +async def test__multi_select_widget__remove_and_limit( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {MULTI_SELECTED_VALUES_KEY: ["a"]} + message.data = {MULTI_SELECT_VALUE_KEY: "a"} + bot = _build_bot_mock() + + widget = MultiSelectWidget( + options=["a", "b"], + label="Выберите", + max_selected=1, + message=message, + bot=bot, + command="/multi-select", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[MULTI_SELECTED_VALUES_KEY] == [] + + message.data = {MULTI_SELECT_VALUE_KEY: "b"} + message.metadata = {MULTI_SELECTED_VALUES_KEY: ["a"]} + widget = MultiSelectWidget( + options=["a", "b"], + label="Выберите", + max_selected=1, + message=message, + bot=bot, + command="/multi-select", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[MULTI_SELECTED_VALUES_KEY] == ["a"] + + +def test__multi_select_widget__get_selected_values( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {MULTI_SELECTED_VALUES_KEY: [1, "2"]} + assert MultiSelectWidget.get_selected_values(message) == ["1", "2"] + + message.metadata = {MULTI_SELECTED_VALUES_KEY: "bad"} + assert MultiSelectWidget.get_selected_values(message) == [] + + +@pytest.mark.asyncio +async def test__multi_select_widget__without_clicked_value( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {MULTI_SELECTED_VALUES_KEY: ["a"]} + message.data = {} + bot = _build_bot_mock() + + widget = MultiSelectWidget( + options=["a", "b"], + label="Выберите", + message=message, + bot=bot, + command="/multi-select", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.metadata[MULTI_SELECTED_VALUES_KEY] == ["a"] diff --git a/tests/widgets/test_observability.py b/tests/widgets/test_observability.py new file mode 100644 index 00000000..9357bfee --- /dev/null +++ b/tests/widgets/test_observability.py @@ -0,0 +1,183 @@ +import importlib +from typing import cast + +import pytest +from pybotx import Bot, IncomingMessage + +from pybotx.widgets import ( + InMemoryWidgetRunnerMetricsCollector, + NoopWidgetRunnerMetricsCollector, + PrometheusWidgetRunnerMetricsCollector, +) +from pybotx.widgets.base import Widget +from pybotx.widgets.runner import WidgetRunnerFinishedEvent, WidgetRunnerStartedEvent + + +class _FakeWidget: + command = "/confirm-demo" + result_mode = "edit" + + +def _build_started_event() -> WidgetRunnerStartedEvent: + return WidgetRunnerStartedEvent( + message=cast(IncomingMessage, object()), + bot=cast(Bot, object()), + widget=cast(Widget, _FakeWidget()), + ) + + +def _build_finished_event( + *, + duration_seconds: float = 0.125, + skipped_by_before: bool = False, + before_results_sent: int = 0, + after_results_sent: int = 0, + error: Exception | None = None, +) -> WidgetRunnerFinishedEvent: + widget = cast(Widget, _FakeWidget()) + return WidgetRunnerFinishedEvent( + message=cast(IncomingMessage, object()), + bot=cast(Bot, object()), + initial_widget=widget, + widget=widget, + duration_seconds=duration_seconds, + displayed=not skipped_by_before, + skipped_by_before=skipped_by_before, + before_results_sent=before_results_sent, + after_results_sent=after_results_sent, + error=error, + ) + + +def test__noop_widget_runner_metrics_collector__does_not_fail() -> None: + collector = NoopWidgetRunnerMetricsCollector() + + collector.on_started(_build_started_event()) + collector.on_finished(_build_finished_event()) + + +def test__in_memory_widget_runner_metrics_collector__collects_metrics() -> None: + collector = InMemoryWidgetRunnerMetricsCollector() + collector.on_started(_build_started_event()) + collector.on_finished( + _build_finished_event( + skipped_by_before=True, + before_results_sent=1, + ), + ) + collector.on_started(_build_started_event()) + collector.on_finished( + _build_finished_event( + duration_seconds=0.5, + after_results_sent=2, + error=RuntimeError("boom"), + ), + ) + snapshot = collector.snapshot() + + assert snapshot.in_flight == 0 + assert snapshot.runs_total == 2 + assert snapshot.errors_total == 1 + assert snapshot.skipped_total == 1 + assert snapshot.before_results_sent_total == 1 + assert snapshot.after_results_sent_total == 2 + assert snapshot.latency_sum_seconds == pytest.approx(0.625) + assert snapshot.run_outcomes[("_FakeWidget", "/confirm-demo", "edit", "ok")] == 1 + assert snapshot.run_outcomes[("_FakeWidget", "/confirm-demo", "edit", "error")] == 1 + assert snapshot.errors[("_FakeWidget", "/confirm-demo", "RuntimeError")] == 1 + assert snapshot.skipped[("_FakeWidget", "/confirm-demo", "edit")] == 1 + assert snapshot.result_messages[("_FakeWidget", "/confirm-demo", "before")] == 1 + assert snapshot.result_messages[("_FakeWidget", "/confirm-demo", "after")] == 2 + + +def test__prometheus_widget_runner_metrics_collector__collects_metrics() -> None: + prometheus_client = pytest.importorskip("prometheus_client") + registry = prometheus_client.CollectorRegistry() + collector = PrometheusWidgetRunnerMetricsCollector(registry=registry) + + collector.on_started(_build_started_event()) + collector.on_finished( + _build_finished_event( + duration_seconds=0.125, + skipped_by_before=True, + before_results_sent=1, + after_results_sent=2, + error=ValueError("boom"), + ), + ) + collector.on_started(_build_started_event()) + collector.on_finished(_build_finished_event()) + + assert registry.get_sample_value( + "pybotx_widget_in_flight", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + }, + ) == 0.0 + assert registry.get_sample_value( + "pybotx_widget_runs_total", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "result_mode": "edit", + "outcome": "error", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_widget_result_messages_total", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "phase": "after", + }, + ) == 2.0 + assert registry.get_sample_value( + "pybotx_widget_errors_total", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "error_type": "ValueError", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_widget_skipped_total", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "result_mode": "edit", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_widget_result_messages_total", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "phase": "before", + }, + ) == 1.0 + assert registry.get_sample_value( + "pybotx_widget_latency_seconds_sum", + { + "widget_name": "_FakeWidget", + "command": "/confirm-demo", + "result_mode": "edit", + "outcome": "error", + }, + ) == pytest.approx(0.125) + + +def test__prometheus_widget_runner_metrics_collector__requires_prometheus_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import_module = importlib.import_module + + def import_module(name: str) -> object: + if name == "prometheus_client": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", import_module) + + with pytest.raises(RuntimeError, match="prometheus-client"): + PrometheusWidgetRunnerMetricsCollector() diff --git a/tests/widgets/test_pagination.py b/tests/widgets/test_pagination.py new file mode 100644 index 00000000..006c699d --- /dev/null +++ b/tests/widgets/test_pagination.py @@ -0,0 +1,317 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import UUID, uuid4 + +import pytest + +from pybotx.widgets.base import ( + PYBOTX_WIDGET_FLAG, + build_outgoing_message_from_incoming, + ensure_widget_metadata, +) +from pybotx.widgets.pagination import ( + MESSAGE_IDS_KEY, + START_FROM_KEY, + PaginationWidget, + _pagination_label, + _safe_int, + _to_uuid_list, +) + + +def _build_bot_mock() -> Any: + message_ids = [ + UUID("00000000-0000-0000-0000-000000000001"), + UUID("00000000-0000-0000-0000-000000000002"), + UUID("00000000-0000-0000-0000-000000000003"), + ] + return SimpleNamespace( + send=AsyncMock(side_effect=message_ids), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__pagination_widget__display_send( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3"], + paginate_by=2, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.send.await_count == 2 + last_message = bot.send.await_args_list[-1].kwargs["message"] + assert last_message.metadata[PYBOTX_WIDGET_FLAG] == 1 + assert "pagination_message_ids" in last_message.metadata + + +@pytest.mark.asyncio +async def test__pagination_widget__display_update( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + message.data = {"pagination_start_from": 1} + message.metadata = { + "pagination_message_ids": [ + "00000000-0000-0000-0000-000000000011", + "00000000-0000-0000-0000-000000000012", + "invalid", + ], + PYBOTX_WIDGET_FLAG: 1, + } + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3"], + paginate_by=2, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.edit_message.await_count >= 1 + + +@pytest.mark.asyncio +async def test__pagination_widget__update_without_source_sync_id( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + message.data = {"pagination_start_from": 0} + message.metadata = { + "pagination_message_ids": ["00000000-0000-0000-0000-000000000021"], + } + message.source_sync_id = None + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2"], + paginate_by=2, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.send.await_count == 1 + + +@pytest.mark.asyncio +async def test__pagination_widget__update_with_short_page( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + message.data = {START_FROM_KEY: 2} + message.metadata = { + MESSAGE_IDS_KEY: [ + "00000000-0000-0000-0000-000000000031", + "00000000-0000-0000-0000-000000000032", + ], + PYBOTX_WIDGET_FLAG: 1, + } + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3"], + paginate_by=3, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.edit_message.await_count >= 2 + + +@pytest.mark.asyncio +async def test__pagination_widget__update_break_on_missing_message_ids( + incoming_message_factory: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("pybotx.widgets.pagination.asyncio.sleep", AsyncMock()) + + message = incoming_message_factory() + message.data = {START_FROM_KEY: 1} + message.metadata = {MESSAGE_IDS_KEY: ["00000000-0000-0000-0000-000000000041"]} + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3"], + paginate_by=3, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.edit_message.await_count >= 1 + + +@pytest.mark.asyncio +async def test__pagination_widget__empty_content( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=[], + paginate_by=2, + message=message, + bot=bot, + command="/pagination", + ) + await widget.display() + + assert bot.send.await_count == 0 + assert bot.edit_message.await_count == 0 + + +def test__pagination_widget__validation_errors( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + with pytest.raises(ValueError): + PaginationWidget( + widget_content=["1"], + paginate_by=0, + message=message, + bot=bot, + command="/pagination", + ) + + with pytest.raises(ValueError): + PaginationWidget( + widget_content=["1"], + paginate_by=1, + delay_between_messages=-1, + message=message, + bot=bot, + command="/pagination", + ) + + +def test__pagination_widget__normalize_outgoing_message( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + content_message = build_outgoing_message_from_incoming( + message=message, + body="original", + metadata={"timestamp": datetime.now(timezone.utc).isoformat()}, + ) + + widget = PaginationWidget( + widget_content=[content_message], + paginate_by=1, + message=message, + bot=bot, + command="/pagination", + ) + + assert widget.widget_content[0].body == "original" + assert widget.widget_content[0].bot_id == message.bot.id + + +def test__pagination_widget__helpers() -> None: + assert _pagination_label(1, 1, backward=True) == "⬅️ Назад к [1]" + assert _pagination_label(1, 3, backward=False) == "➡️ Вперёд к [1-3]" + assert _safe_int(2, 0) == 2 + assert _safe_int("7", 0) == 7 + assert _safe_int("x", 9) == 9 + assert _to_uuid_list("bad") == [] + assert _to_uuid_list(["bad"]) == [] + + +def test__pagination_widget__add_navigation_buttons( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {START_FROM_KEY: 2} + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3", "4"], + paginate_by=2, + message=message, + bot=bot, + command="/pagination", + ) + widget.add_markup() + + labels = [button.label for row in widget.widget_bubbles for button in row] + assert labels == ["⬅️ Назад к [1-2]"] + + +def test__pagination_widget__negative_start_from( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {START_FROM_KEY: -10} + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1", "2", "3"], + paginate_by=1, + message=message, + bot=bot, + command="/pagination", + ) + + assert widget.start_from == 0 + + +def test__pagination_widget__prepare_last_message_keeps_metadata( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = PaginationWidget( + widget_content=["1"], + paginate_by=1, + message=message, + bot=bot, + command="/pagination", + ) + widget.message_ids = [uuid4()] + content_message = build_outgoing_message_from_incoming( + message=message, + body="body", + metadata={"foo": "bar"}, + ) + + widget._prepare_last_message(content_message) + + metadata = ensure_widget_metadata(content_message) + assert metadata["foo"] == "bar" + assert metadata[PYBOTX_WIDGET_FLAG] == 1 + assert MESSAGE_IDS_KEY in metadata diff --git a/tests/widgets/test_presets.py b/tests/widgets/test_presets.py new file mode 100644 index 00000000..ab486c41 --- /dev/null +++ b/tests/widgets/test_presets.py @@ -0,0 +1,150 @@ +from typing import cast + +import pytest + +from pybotx.widgets import ( + LoggingWidgetRunnerObserver, + PrometheusWidgetRunnerMetricsCollector, + RunnerHookResult, + WidgetRunnerConfig, + WidgetRunnerObservabilityPreset, + WidgetRunnerObserver, + WidgetRunnerPreset, + build_production_widget_runner_preset, + build_widget_runner_observability_preset, +) +from pybotx.widgets.base import Widget + + +def test__build_widget_runner_observability_preset__adds_logging_observer() -> None: + preset = build_widget_runner_observability_preset() + + assert isinstance(preset, WidgetRunnerObservabilityPreset) + assert len(preset.observers) == 1 + assert isinstance(preset.observers[0], LoggingWidgetRunnerObserver) + assert preset.as_config() == WidgetRunnerConfig(observers=preset.observers) + + +def test__build_widget_runner_observability_preset__supports_custom_observers() -> None: + class _CustomObserver(WidgetRunnerObserver): + def on_started(self, _event: object) -> None: + return None + + def on_finished(self, _event: object) -> None: + return None + + custom_observer = _CustomObserver() + + preset = build_widget_runner_observability_preset( + observers=(custom_observer,), + ) + + assert preset.observers[1] is custom_observer + + +def test__build_widget_runner_observability_preset__requires_at_least_one_observer() -> None: + with pytest.raises(ValueError): + build_widget_runner_observability_preset(enable_logging_observer=False) + + +def test__build_widget_runner_observability_preset__supports_prometheus_metrics() -> None: + prometheus_client = pytest.importorskip("prometheus_client") + registry = prometheus_client.CollectorRegistry() + + preset = build_widget_runner_observability_preset( + enable_logging_observer=False, + enable_prometheus_metrics=True, + registry=registry, + ) + + assert len(preset.observers) == 1 + assert isinstance(preset.observers[0], PrometheusWidgetRunnerMetricsCollector) + + +def test__build_production_widget_runner_preset__builds_runner_config() -> None: + calls: list[str] = [] + + def _before(_: Widget) -> RunnerHookResult: + calls.append("before") + return RunnerHookResult(result="before", should_display=True) + + def _after(_: Widget) -> str | None: + calls.append("after") + return "after" + + preset = build_production_widget_runner_preset( + before=_before, + after=_after, + ) + + assert isinstance(preset, WidgetRunnerPreset) + assert preset.as_runner_kwargs() == {"config": preset.config} + assert preset.as_config() is preset.config + assert preset.as_widget_command_kwargs() == {"config": preset.config} + assert len(preset.config.before_hooks) == 1 + assert len(preset.config.after_hooks) == 1 + assert len(preset.config.observers) == 1 + assert isinstance(preset.config.observers[0], LoggingWidgetRunnerObserver) + + widget = cast(Widget, object()) + before_result = preset.config.before_hooks[0](widget) + after_result = preset.config.after_hooks[0](widget) + + assert isinstance(before_result, RunnerHookResult) + assert before_result.result == "before" + assert after_result == "after" + assert calls == ["before", "after"] + + +def test__build_production_widget_runner_preset__extends_with_extra_hooks() -> None: + order: list[str] = [] + + def _before_one(_: Widget) -> RunnerHookResult: + order.append("before_one") + return RunnerHookResult() + + def _before_two(_: Widget) -> RunnerHookResult: + order.append("before_two") + return RunnerHookResult() + + def _after_one(_: Widget) -> str | None: + order.append("after_one") + return None + + def _after_two(_: Widget) -> str | None: + order.append("after_two") + return None + + preset = build_production_widget_runner_preset( + before=_before_one, + before_hooks=(_before_two,), + after=_after_one, + after_hooks=(_after_two,), + observability_preset=WidgetRunnerObservabilityPreset(observers=()), + ) + + widget = cast(Widget, object()) + for before_hook in preset.config.before_hooks: + before_hook(widget) + for after_hook in preset.config.after_hooks: + after_hook(widget) + + assert order == ["before_one", "before_two", "after_one", "after_two"] + + +def test__build_production_widget_runner_preset__uses_custom_observability_preset() -> None: + class _CustomObserver(WidgetRunnerObserver): + def on_started(self, _event: object) -> None: + return None + + def on_finished(self, _event: object) -> None: + return None + + custom_observer = _CustomObserver() + observability_preset = WidgetRunnerObservabilityPreset(observers=(custom_observer,)) + + preset = build_production_widget_runner_preset( + observability_preset=observability_preset, + ) + + assert preset.config.observers == (custom_observer,) diff --git a/tests/widgets/test_range.py b/tests/widgets/test_range.py new file mode 100644 index 00000000..2ff957e5 --- /dev/null +++ b/tests/widgets/test_range.py @@ -0,0 +1,160 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.base import PYBOTX_WIDGET_FLAG +from pybotx.widgets.range import CURRENT_INDEX_KEY, ELEMENTS_KEY, RangeWidget + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__range_widget__display_first_element( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = RangeWidget( + elements=["1", "2", "3"], + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + + assert sent_message.body == "Current element: 1" + assert sent_message.metadata[ELEMENTS_KEY] == ["1", "2", "3"] + assert rows == [["Вперед"]] + + +@pytest.mark.asyncio +async def test__range_widget__display_middle_element( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {CURRENT_INDEX_KEY: 1} + message.metadata = {PYBOTX_WIDGET_FLAG: 1, ELEMENTS_KEY: ["1", "2", "3"]} + message.source_sync_id = uuid4() + bot = _build_bot_mock() + + widget = RangeWidget( + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + assert bot.edit_message.await_count == 1 + edited_message_bubbles = bot.edit_message.await_args.kwargs["bubbles"] + rows = [[button.label for button in row] for row in edited_message_bubbles] + assert rows == [["Назад", "Вперед"]] + + +@pytest.mark.asyncio +async def test__range_widget__display_last_element( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {CURRENT_INDEX_KEY: 999} + bot = _build_bot_mock() + + widget = RangeWidget( + elements=["1", "2", "3"], + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + rows = [[button.label for button in row] for row in sent_message.bubbles] + assert sent_message.body == "Current element: 3" + assert rows == [["Назад"]] + + +@pytest.mark.asyncio +async def test__range_widget__empty_list( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = RangeWidget( + elements=[], + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body == "Список пуст" + assert list(sent_message.bubbles) == [] + + +@pytest.mark.asyncio +async def test__range_widget__invalid_elements_in_metadata( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.metadata = {ELEMENTS_KEY: "bad"} + bot = _build_bot_mock() + + widget = RangeWidget( + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body == "Список пуст" + + +@pytest.mark.asyncio +async def test__range_widget__negative_index_is_clamped( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {CURRENT_INDEX_KEY: -10} + bot = _build_bot_mock() + + widget = RangeWidget( + elements=["a", "b"], + message=message, + bot=bot, + command="/range-widget", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body == "Current element: a" + + +def test__range_widget__helpers( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {CURRENT_INDEX_KEY: "1"} + message.metadata = {ELEMENTS_KEY: ["x", "y"]} + assert RangeWidget.get_value(message) == "y" + + message.data = {CURRENT_INDEX_KEY: -1} + with pytest.raises(RuntimeError): + RangeWidget.get_value(message) + + message.metadata = {ELEMENTS_KEY: "bad"} + with pytest.raises(RuntimeError): + RangeWidget.get_value(message) diff --git a/tests/widgets/test_runner.py b/tests/widgets/test_runner.py new file mode 100644 index 00000000..54e6bd42 --- /dev/null +++ b/tests/widgets/test_runner.py @@ -0,0 +1,529 @@ +from collections.abc import Callable +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 +from typing import Any + +import pytest +from pybotx import Bot, IncomingMessage + +from pybotx.widgets.base import Widget +from pybotx.widgets.runner import ( + LoggingWidgetRunnerObserver, + RunnerHookResult, + WidgetRunner, + WidgetRunnerConfig, + WidgetRunnerFinishedEvent, + WidgetRunnerObserver, + WidgetRunnerStartedEvent, + on_action, + on_completed, + on_data_key, + widget_command, + _resolve_awaitable, +) + + +class _DummyWidget(Widget): + def add_markup(self) -> None: + self.widget_message.body = "dummy" + + +class _AltWidget(Widget): + def add_markup(self) -> None: + self.widget_message.body = "alt" + + +class _FailingWidget(Widget): + def add_markup(self) -> None: + raise RuntimeError("Widget rendering failed.") + + +class _RecordingObserver(WidgetRunnerObserver): + def __init__(self) -> None: + self.started: list[WidgetRunnerStartedEvent] = [] + self.finished: list[WidgetRunnerFinishedEvent] = [] + + async def on_started(self, event: WidgetRunnerStartedEvent) -> None: + self.started.append(event) + + async def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + self.finished.append(event) + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + answer_message=AsyncMock(return_value=uuid4()), + ) + + +@pytest.mark.asyncio +async def test__widget_runner__display_only( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run(widget=widget) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__before_result_skip_display( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + def _before(_: _DummyWidget) -> RunnerHookResult: + return RunnerHookResult(result="before", should_display=False) + + await runner.run(widget=widget, before=_before) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "before" + + +@pytest.mark.asyncio +async def test__widget_runner__before_result_and_display( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + def _before(_: _DummyWidget) -> RunnerHookResult: + return RunnerHookResult(result="before", should_display=True) + + await runner.run(widget=widget, before=_before) + + assert bot.send.await_count == 2 + assert bot.send.await_args_list[0].kwargs["message"].body == "before" + assert bot.send.await_args_list[1].kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__before_replace_widget( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + replacement = _AltWidget(message=message, bot=bot, command="/alt") + + def _before(_: _DummyWidget) -> RunnerHookResult: + return RunnerHookResult(widget=replacement) + + await runner.run(widget=widget, before=_before) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "alt" + + +@pytest.mark.asyncio +async def test__widget_runner__after_async_result( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + async def _after(_: _DummyWidget) -> str | None: + return "after" + + await runner.run(widget=widget, after=_after) + + assert bot.send.await_count == 2 + assert bot.send.await_args_list[0].kwargs["message"].body == "dummy" + assert bot.send.await_args_list[1].kwargs["message"].body == "after" + + +@pytest.mark.asyncio +async def test__widget_runner__on_data_key_skip_display( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + message.data["select"] = "value" + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run( + widget=widget, + before=on_data_key("select", lambda _: "selected"), + ) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "selected" + + +@pytest.mark.asyncio +async def test__widget_runner__on_data_key_keep_display( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + message.data["select"] = "value" + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run( + widget=widget, + before=on_data_key( + "select", + lambda _: "selected", + should_display=True, + ), + ) + + assert bot.send.await_count == 2 + assert bot.send.await_args_list[0].kwargs["message"].body == "selected" + assert bot.send.await_args_list[1].kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__on_completed_after( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run( + widget=widget, + after=on_completed(lambda _: True, lambda _: "completed"), + ) + + assert bot.send.await_count == 2 + assert bot.send.await_args_list[0].kwargs["message"].body == "dummy" + assert bot.send.await_args_list[1].kwargs["message"].body == "completed" + + +@pytest.mark.asyncio +async def test__widget_runner__on_action_map( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + message.data["action"] = "confirm" + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run( + widget=widget, + before=on_action( + lambda current_widget: str(current_widget.message.data["action"]), + {"confirm": "confirmed"}, + ), + ) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "confirmed" + + +@pytest.mark.asyncio +async def test__widget_runner__on_action_missing_value_is_ignored( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + runner = WidgetRunner(message, bot) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + def _getter(current_widget: _DummyWidget) -> str: + action = current_widget.message.data.get("action") + if action is None: + raise RuntimeError("Action is missing.") + return str(action) + + await runner.run( + widget=widget, + before=on_action(_getter, {"confirm": "confirmed"}), + ) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__widget_command_decorator( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + message.data["selected"] = "1" + bot = _build_bot_mock() + + @widget_command( + before=on_data_key( + "selected", + lambda _: "handled by decorator", + ), + ) + async def _handler( + wrapped_message: IncomingMessage, + wrapped_bot: Bot, + ) -> _DummyWidget: + return _DummyWidget( + message=wrapped_message, + bot=wrapped_bot, + command="/dummy", + ) + + await _handler(message, bot) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "handled by decorator" + + +@pytest.mark.asyncio +async def test__widget_runner__config_before_runs_before_call_before( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + calls: list[str] = [] + + def _config_before(_: _DummyWidget) -> RunnerHookResult: + calls.append("config_before") + return RunnerHookResult(result="config", should_display=True) + + def _call_before(_: _DummyWidget) -> RunnerHookResult: + calls.append("call_before") + return RunnerHookResult(result="call", should_display=True) + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig.from_hooks(before=_config_before), + ) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run(widget=widget, before=_call_before) + + assert calls == ["config_before", "call_before"] + assert bot.send.await_count == 3 + assert bot.send.await_args_list[0].kwargs["message"].body == "config" + assert bot.send.await_args_list[1].kwargs["message"].body == "call" + assert bot.send.await_args_list[2].kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__config_after_runs_after_call_after( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + calls: list[str] = [] + + def _call_after(_: _DummyWidget) -> str | None: + calls.append("call_after") + return "call" + + def _config_after(_: _DummyWidget) -> str | None: + calls.append("config_after") + return "config" + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig.from_hooks(after=_config_after), + ) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run(widget=widget, after=_call_after) + + assert calls == ["call_after", "config_after"] + assert bot.send.await_count == 3 + assert bot.send.await_args_list[0].kwargs["message"].body == "dummy" + assert bot.send.await_args_list[1].kwargs["message"].body == "call" + assert bot.send.await_args_list[2].kwargs["message"].body == "config" + + +@pytest.mark.asyncio +async def test__widget_runner__observer_receives_started_and_finished_events( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + observer = _RecordingObserver() + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig(observers=(observer, LoggingWidgetRunnerObserver())), + ) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run(widget=widget) + + assert len(observer.started) == 1 + assert observer.started[0].widget_name == "_DummyWidget" + assert len(observer.finished) == 1 + assert observer.finished[0].widget_name == "_DummyWidget" + assert observer.finished[0].displayed is True + assert observer.finished[0].error is None + assert observer.finished[0].before_results_sent == 0 + assert observer.finished[0].after_results_sent == 0 + + +@pytest.mark.asyncio +async def test__widget_runner__observer_errors_do_not_break_execution( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + class _BrokenObserver(WidgetRunnerObserver): + async def on_started(self, event: WidgetRunnerStartedEvent) -> None: + raise RuntimeError("start failed") + + async def on_finished(self, event: WidgetRunnerFinishedEvent) -> None: + raise RuntimeError("finish failed") + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig(observers=(_BrokenObserver(),)), + ) + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + + await runner.run(widget=widget) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "dummy" + + +@pytest.mark.asyncio +async def test__widget_runner__observer_receives_error_event( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + observer = _RecordingObserver() + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig(observers=(observer,)), + ) + widget = _FailingWidget(message=message, bot=bot, command="/dummy") + + with pytest.raises(RuntimeError, match="Widget rendering failed"): + await runner.run(widget=widget) + + assert len(observer.started) == 1 + assert len(observer.finished) == 1 + assert observer.finished[0].displayed is False + assert observer.finished[0].error is not None + assert str(observer.finished[0].error) == "Widget rendering failed." + + +@pytest.mark.asyncio +async def test__widget_runner__widget_command_supports_config( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + def _config_before(_: _DummyWidget) -> RunnerHookResult: + return RunnerHookResult(result="configured", should_display=False) + + config = WidgetRunnerConfig.from_hooks(before=_config_before) + + @widget_command(config=config) + async def _handler( + wrapped_message: IncomingMessage, + wrapped_bot: Bot, + ) -> _DummyWidget: + return _DummyWidget( + message=wrapped_message, + bot=wrapped_bot, + command="/dummy", + ) + + await _handler(message, bot) + + assert bot.send.await_count == 1 + assert bot.send.await_args.kwargs["message"].body == "configured" + + +@pytest.mark.asyncio +async def test__widget_runner__edge_paths( + incoming_message_factory: Callable[[], IncomingMessage], +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + widget = _DummyWidget(message=message, bot=bot, command="/dummy") + started = WidgetRunnerStartedEvent(message=message, bot=bot, widget=widget) + finished = WidgetRunnerFinishedEvent( + message=message, + bot=bot, + initial_widget=widget, + widget=widget, + duration_seconds=0, + displayed=False, + skipped_by_before=False, + before_results_sent=0, + after_results_sent=0, + error=RuntimeError("failed"), + ) + observer = WidgetRunnerObserver() + + assert started.result_mode == widget.result_mode + assert observer.on_started(started) is None + assert observer.on_finished(finished) is None + await LoggingWidgetRunnerObserver(log_start=True).on_started(started) + await LoggingWidgetRunnerObserver().on_finished(finished) + + runner = WidgetRunner( + message, + bot, + config=WidgetRunnerConfig(after_hooks=(lambda _widget: None,)), + ) + await runner.run(widget=widget) + + @widget_command + def direct_handler(_message: IncomingMessage, _bot: Bot) -> None: + return None + + await direct_handler(message, bot) + assert await _resolve_awaitable( + on_data_key("missing", lambda _: "unused")(widget), + ) is None + assert await _resolve_awaitable( + on_completed(lambda _: False, lambda _: "unused")(widget), + ) is None + assert await _resolve_awaitable(on_action(lambda _: "missing", {})(widget)) is None + assert await _resolve_awaitable( + on_action(lambda _: "run", {"run": lambda _: "done"})(widget), + ) == ( + RunnerHookResult(result="done", should_display=False) + ) + assert await _resolve_awaitable( + on_action( + lambda _: "run", + {"run": RunnerHookResult(result="ready")}, + )(widget), + ) == RunnerHookResult(result="ready") diff --git a/tests/widgets/test_search_select.py b/tests/widgets/test_search_select.py new file mode 100644 index 00000000..3cec7433 --- /dev/null +++ b/tests/widgets/test_search_select.py @@ -0,0 +1,127 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.search_select import SEARCH_PAGE_KEY, SEARCH_QUERY_KEY, SearchSelectWidget + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__search_select_widget__display_without_query( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SEARCH_PAGE_KEY: "1"} + bot = _build_bot_mock() + + widget = SearchSelectWidget( + options=["a", "b", "c", "d"], + label="Поиск", + page_size=2, + message=message, + bot=bot, + command="/search", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["c", "d", "Назад"] + + +@pytest.mark.asyncio +async def test__search_select_widget__first_page_forward_only( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SEARCH_PAGE_KEY: 0} + bot = _build_bot_mock() + + widget = SearchSelectWidget( + options=["a", "b", "c"], + label="Поиск", + page_size=1, + message=message, + bot=bot, + command="/search", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["a", "Вперед"] + + +@pytest.mark.asyncio +async def test__search_select_widget__query_with_results( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SEARCH_QUERY_KEY: "ap", SEARCH_PAGE_KEY: 100} + bot = _build_bot_mock() + + widget = SearchSelectWidget( + options=["apple", "banana", "apricot", "orange"], + label="Поиск", + page_size=1, + message=message, + bot=bot, + command="/search", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert "Поиск: ap" in sent_message.body + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["apricot", "Назад"] + + +@pytest.mark.asyncio +async def test__search_select_widget__middle_page_controls( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SEARCH_QUERY_KEY: "a", SEARCH_PAGE_KEY: 1} + bot = _build_bot_mock() + + widget = SearchSelectWidget( + options=["aa", "ab", "ac", "ad"], + label="Поиск", + page_size=1, + message=message, + bot=bot, + command="/search", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["ab", "Назад", "Вперед"] + + +@pytest.mark.asyncio +async def test__search_select_widget__empty_result( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory(body="/search qwerty") + bot = _build_bot_mock() + + widget = SearchSelectWidget( + options=["apple", "banana"], + label="Поиск", + page_size=1, + message=message, + bot=bot, + command="/search", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Ничего не найдено"] diff --git a/tests/widgets/test_select.py b/tests/widgets/test_select.py new file mode 100644 index 00000000..3ac08687 --- /dev/null +++ b/tests/widgets/test_select.py @@ -0,0 +1,49 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.select import SELECT_VALUE_KEY, SelectOption, SelectWidget + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +@pytest.mark.asyncio +async def test__select_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECT_VALUE_KEY: "b"} + bot = _build_bot_mock() + + widget = SelectWidget( + options=[ + "a", + SelectOption(value="b", label="Bee"), + ], + label="Выберите", + message=message, + bot=bot, + command="/select", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["a", "• Bee"] + + +def test__select_widget__get_value( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {SELECT_VALUE_KEY: "value"} + assert SelectWidget.get_value(message) == "value" + + message.data = {} + with pytest.raises(RuntimeError): + SelectWidget.get_value(message) diff --git a/tests/widgets/test_service.py b/tests/widgets/test_service.py new file mode 100644 index 00000000..17c05629 --- /dev/null +++ b/tests/widgets/test_service.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.base import PYBOTX_WIDGET_FLAG +from pybotx.widgets.markup import MessageMarkup +from pybotx.widgets.service import send_or_update_message + + +def _build_bot_mock() -> Any: + return SimpleNamespace( + send=AsyncMock(return_value=uuid4()), + edit_message=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test__send_or_update_message__send( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + markup = MessageMarkup() + markup.add_bubble(command="/send", label="Send") + + await send_or_update_message(message, bot, body="body", markup=markup) + + assert bot.send.await_count == 1 + assert bot.edit_message.await_count == 0 + + +@pytest.mark.asyncio +async def test__send_or_update_message__update( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.source_sync_id = uuid4() + message.metadata[PYBOTX_WIDGET_FLAG] = 1 + bot = _build_bot_mock() + + await send_or_update_message(message, bot, body="body") + + assert bot.send.await_count == 0 + assert bot.edit_message.await_count == 1 diff --git a/tests/widgets/test_table.py b/tests/widgets/test_table.py new file mode 100644 index 00000000..b79ee3f1 --- /dev/null +++ b/tests/widgets/test_table.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from pybotx.widgets.table import ( + TABLE_DESC_KEY, + TABLE_PAGE_KEY, + TABLE_QUERY_KEY, + TABLE_SORT_KEY, + TableWidget, + _safe_bool, + _safe_int, +) + + +def _build_bot_mock() -> Any: + return SimpleNamespace(send=AsyncMock(return_value=uuid4()), edit_message=AsyncMock()) + + +def test__table_widget__helpers() -> None: + assert _safe_int(1) == 1 + assert _safe_int("2") == 2 + assert _safe_int("bad") == 0 + + assert _safe_bool(True) + assert _safe_bool("true") + assert _safe_bool("1") + assert not _safe_bool("no") + assert not _safe_bool(None) + + +@pytest.mark.asyncio +async def test__table_widget__display( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = { + TABLE_QUERY_KEY: "a", + TABLE_SORT_KEY: "name", + TABLE_DESC_KEY: "true", + TABLE_PAGE_KEY: 0, + } + bot = _build_bot_mock() + + widget = TableWidget( + rows=[ + {"name": "Alice", "role": "admin"}, + {"name": "Bob", "role": "user"}, + {"name": "Charlie", "role": "admin"}, + ], + columns=["name", "role"], + label="Пользователи", + page_size=1, + message=message, + bot=bot, + command="/table", + ) + await widget.display() + + sent_message = bot.send.await_args.kwargs["message"] + assert "Пользователи" in sent_message.body + assert "Фильтр: a" in sent_message.body + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Вперед", "Сорт: name", "Сорт: role", "Сбросить фильтр"] + + +@pytest.mark.asyncio +async def test__table_widget__pagination_and_no_data( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + message.data = {TABLE_PAGE_KEY: 5} + bot = _build_bot_mock() + + widget = TableWidget( + rows=[{"name": "A"}, {"name": "B"}], + columns=["name"], + page_size=1, + message=message, + bot=bot, + command="/table", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + labels = [button.label for row in sent_message.bubbles for button in row] + assert labels == ["Назад", "Сорт: name"] + + message = incoming_message_factory() + widget = TableWidget( + rows=[], + columns=["name"], + message=message, + bot=bot, + command="/table", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert "Нет данных" in sent_message.body + + +@pytest.mark.asyncio +async def test__table_widget__without_sort_column( + incoming_message_factory: Any, +) -> None: + message = incoming_message_factory() + bot = _build_bot_mock() + + widget = TableWidget( + rows=[{"name": "A"}], + columns=[], + message=message, + bot=bot, + command="/table", + ) + await widget.display() + sent_message = bot.send.await_args.kwargs["message"] + assert sent_message.body.startswith("Таблица") diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..9d7e4099 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1302 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4.0" + +[[package]] +name = "aiocsv" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/4c/07c7baeb5bf3e9c4f64d4d31430d41fc00c301fa0f43644f64b09127b7f7/aiocsv-1.4.0.tar.gz", hash = "sha256:f3877e5ef493616a1e0c1365744bbff89184b6f84e9a70859fe319d92060079a", size = 25872, upload-time = "2025-09-22T12:04:26.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/47/7732f6fb085788a68623f106597386985866b292eb57ae5d905b9e1180f3/aiocsv-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34f971a0d20100154affec72c8e4f4191a50a92eea321988e09342cab74c4bfe", size = 27309, upload-time = "2025-09-22T12:12:21.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/49/229b5cf340b4b989a92db3391ccd102754da7b0bec2e83c1ba6c1cde4a83/aiocsv-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2395d5608664760ceab402afef5f3ea4978490ce07d90d7bdfe6f9952eb10526", size = 27794, upload-time = "2025-09-22T12:12:22.477Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8f/fba052a0d811d6e32d9a5b614caeb622021597ebce9759f84a19b129fbeb/aiocsv-1.4.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f5e86d872668eff761f537f1e8168030917358f2e571cd01d360eb360917fce", size = 48573, upload-time = "2025-09-22T12:12:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/bc5585056a2b55552fda49b0d7abc696ca9d10ba7cfe2a173144e3d3e9e1/aiocsv-1.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b67c6fb3afca5805f464f97067c09b59fff6d19fb805f7a7cb53591b44191703", size = 49109, upload-time = "2025-09-22T12:12:24.291Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a7/2ca0720bbc3e888d9546982a30fcd094ecb71afb23960c7cf4569f4549be/aiocsv-1.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c96be3bf9d726d67099c5ade15255337d6b0288e92282ebb93379b076bf46b54", size = 48439, upload-time = "2025-09-22T12:12:25.353Z" }, + { url = "https://files.pythonhosted.org/packages/ae/03/cbfe66df1c8b0ac8e425cd9ff7ab0eff29dd73e6bccea5b7fff69904d02c/aiocsv-1.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9f1caf2414a74a10ef7ab220898a87bbf09f4c6b7c96b6622f55206746d78e6", size = 48384, upload-time = "2025-09-22T12:12:26.074Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/ad240f55fd397fc10232fe24bf0511b559ce30feecaa0d4ee06f0e4e5989/aiocsv-1.4.0-cp310-cp310-win32.whl", hash = "sha256:37caa55e3a72ca0e7b51995749e98a3afc011cea6d86633878cf45e2dc2c82d9", size = 29082, upload-time = "2025-09-22T12:12:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/41b496212896f7513b664e684a94c1a5993101d20bb78588b55f62faa011/aiocsv-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:30d21c37bb61825f2ee067b45f0d261da39545517cec995ba18e0bb20670bc62", size = 29906, upload-time = "2025-09-22T12:12:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/78f794c19d12a245a3f63a88b709893c67eac3e4e959dcad0c1fc396ed51/aiocsv-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecdaaf771f8c76076d7c491f7b189f3ff13dea2fbb6cccc020b900dce904e3d3", size = 27308, upload-time = "2025-09-22T12:12:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ee/b89ace06ab92ebbff4d211cc941a31a726885568a6c6f10b17e0d1220754/aiocsv-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f38933132a22ea5ebb8123f8f72fd72a2f014631e21e205c7bbf4d4a68721d84", size = 27789, upload-time = "2025-09-22T12:12:29.706Z" }, + { url = "https://files.pythonhosted.org/packages/0f/47/6af87a3c185643bd0ef809fd028abe527b2d348fb7384f13207a3eaadaae/aiocsv-1.4.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1f0bdddada91ebfe08f12f7631f5b12a1578a82fb61f91bdf0a4a4ff93fe3099", size = 49855, upload-time = "2025-09-22T12:12:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/3a/80/7dd86bbe1f7e32f851ed76f2c40fdb89766ce8614f734a154a66e8506f5d/aiocsv-1.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ab69a41409ec1731624ed25e39d18544bb1c9cb21283f2eb1d9c02bad56e91c", size = 50512, upload-time = "2025-09-22T12:12:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/888737628706f591916e86c66eb8f869ccfd3f7bf5df9ea4e8ee7bb36ce7/aiocsv-1.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e07c3bac73968bd4a676377dfa23027d00270d918115d0faf8ce087df6ab1d83", size = 49745, upload-time = "2025-09-22T12:12:32.723Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/c281def204976ecc093bfd14e9ccfc6c0786d083e378e35b56e2d8fc4fac/aiocsv-1.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bd92976566ecb250301d35096f9334e6aadf987b58b78a3fd6434b44318d98b6", size = 49773, upload-time = "2025-09-22T12:12:33.483Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/4a36a2939daa910a479ac670f1bc9d189f03aa48c14f56a56f8119c568b7/aiocsv-1.4.0-cp311-cp311-win32.whl", hash = "sha256:281da9eb23626482fcf47b0c83e7166e9221f143e1a0e64cebd87996a45c6c56", size = 29096, upload-time = "2025-09-22T12:12:34.266Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/aa3dd06f82fd38fc4a9b08a6e014060afc6f2d86a674f2be2484f68e25fa/aiocsv-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64de35600a125752303a649b0df2edae73e9c6c8b5ba5dee2775f2d1f39a69e7", size = 29885, upload-time = "2025-09-22T12:12:34.983Z" }, + { url = "https://files.pythonhosted.org/packages/63/f2/39263ef43bf9c58878726770a282e7a8ac9f1ba8b5ad51905a6980f773c9/aiocsv-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eaf0caa8a951df866cf0b7ff6298a142fa76555c5b338058d372d26619682b93", size = 27369, upload-time = "2025-09-22T12:12:35.707Z" }, + { url = "https://files.pythonhosted.org/packages/8f/76/f0c739bf3a082f9d01549a846f7f6e4f55496422a587df47d947b1b7c858/aiocsv-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3e587d38cc97a7f2c45316c75995b43c35c8f9d77840ef76226e53d4beeebe94", size = 27789, upload-time = "2025-09-22T12:12:36.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/ae5c14fbad40893ecaa4d4c2dc8e371475942a195cffcfae31a7b726ebbe/aiocsv-1.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e08fdb3bb6f531f3ce6bc15158368cfe348fa2a483be390e5a4a972c24c5a867", size = 51638, upload-time = "2025-09-22T12:12:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/82a958d4856ee9cf9b197f67806c4678319dc8457f18cc2a0afee4b84d00/aiocsv-1.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97a9db9e073ae1afbfc479dbfdc3764eab1866b7678766d4a7c226b09ee55a05", size = 52134, upload-time = "2025-09-22T12:12:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/59/c4/557c27d2860a0a5cdb8654f3b037bc0ee467dad8289652e0b118ed4a6898/aiocsv-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:890f41d90de7e6a5340098e914dae47cd9580e077172b7baa5ba5dbd3c561b29", size = 51073, upload-time = "2025-09-22T12:12:39.036Z" }, + { url = "https://files.pythonhosted.org/packages/fa/31/0cae5df2db2bac169f978e939e873bf0d8c2cabbad1952618f66ff35c3d8/aiocsv-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9b7c2935c585499320ca94d98ed7bc8cd67db770b25fa6c5e6f72bff4782575", size = 51309, upload-time = "2025-09-22T12:12:40.015Z" }, + { url = "https://files.pythonhosted.org/packages/00/eb/79174f613b2a63b8a8574767fb0fdfb6402396520b66e31baeff2808573a/aiocsv-1.4.0-cp312-cp312-win32.whl", hash = "sha256:736ff2465414d7e047915ddae5fa48f7ed755d125e60b08ff6d59450650ef9b3", size = 29138, upload-time = "2025-09-22T12:12:40.782Z" }, + { url = "https://files.pythonhosted.org/packages/ea/22/c1f0af5592a00d7ce5856e2679c311de9e868c1855b8fe37463c40dc5caa/aiocsv-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1c727460a4e82cd2aba43db0ed94a310433ec81b55e21ceb9b504053db558f6", size = 29938, upload-time = "2025-09-22T12:12:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/27/51/e9df3b9fb7364ecf9712cd84b943065fc9451003b406729659b745cbd8b3/aiocsv-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0fca59ed869218a403496a05f0d4bf99affc4d8191ed930f34df1c5c8240d0e8", size = 27303, upload-time = "2025-09-22T12:12:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/39/9b/eedb39689095cae98b4725cefc33166a8dce8b4c055770706c53dbaebae4/aiocsv-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d14f5fc7e84f5e3e142eb8a9ec57442688532d5f891a5dd86a517e4ccd768907", size = 27718, upload-time = "2025-09-22T12:12:42.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/c316e8647850ba430a7a66e9fb79a02f96537f2a6606455ce08ba7c12673/aiocsv-1.4.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6eeed4d5b9fb2d78666ad0b84ef85609ff3da237bff1d39e8c8c9c8e68ee06f9", size = 51307, upload-time = "2025-09-22T12:12:43.633Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/740b7bb0945b56e75157440a720b575083175195793abe92a29513fdb155/aiocsv-1.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccf1cb4280476c167aa5f83569f8640e22deff29bd0575076e5f8ebddd5bfc53", size = 51818, upload-time = "2025-09-22T12:12:44.393Z" }, + { url = "https://files.pythonhosted.org/packages/21/73/21abf05c852f1d46dc25673549be2b18ee464c14f52e4a928fc66c92e57b/aiocsv-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66b5e7ae6388e0cf8928f73a414ae770615c84a47f1c3d959b400fbcb1ff25d6", size = 50819, upload-time = "2025-09-22T12:12:45.525Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/33229fdd5edcc3cab81084c43093c971f2090a8b68640b4a7a490aef0f19/aiocsv-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7dc66116ca2856c50f3b00d7577a67b8ab623335b2f397144e7bc4a812c58485", size = 51047, upload-time = "2025-09-22T12:12:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c9/6f06c70b95154fe14461831416a035c852775b2e163750f56cd5cae0c33d/aiocsv-1.4.0-cp313-cp313-win32.whl", hash = "sha256:e868eecaae6739658541f4418b095bc94e6f62584f3e2d80777c1335e738e463", size = 29118, upload-time = "2025-09-22T12:12:47.741Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9f/71b4ea5bc1adb89250a534703752b1c4009c9f9a749cf4bcd00941c41af3/aiocsv-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5c20240f59a8a6faa288a56f99b196b1754bc1c8d52233e59c6a6df03c47f86", size = 29915, upload-time = "2025-09-22T12:12:48.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/fc/0bd4bf6d3a60800afd6dbbdbfd972e8b8d92e95458491a8dff79c8b0b15a/aiocsv-1.4.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:03a91aa96c3341277956b95248e818628d36f7218cebcba36262a1bc014a183d", size = 27248, upload-time = "2025-09-22T12:12:49.562Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/93bd48f3c390bdbe973e5ec497ce81b32046208fdeb6c54632f85074c3fc/aiocsv-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b2b95faa180a8e7563a44545a9fb7a0ab535f827729183a7d595c69337f604a5", size = 27722, upload-time = "2025-09-22T12:12:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4b/0b298d72ca1dc0126a447f0ffb9c5be55ba9c0dd6f9604376aa140a1fce6/aiocsv-1.4.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e4ee48ddf88e63a27caf6f3a797cc2107113a6cd4a2579a90a0ce7066ecf29fd", size = 51328, upload-time = "2025-09-22T12:12:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/71/82/b3b461e929cdd8dd92637c683f81ea885234aecd8c9d1058c79b07a2959f/aiocsv-1.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe2462c5da8387fe594d2905cab096abf2982c6d44c1eb1ee03ccca183ea5338", size = 51862, upload-time = "2025-09-22T12:12:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4d/c40e1b2f509cc1ea763edc7f6e8fb77d55a75b03b7107bb39848177e2ac6/aiocsv-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c3147be681208231d53367794cbcde557c461ec2cc6e240f68ce637176bcf172", size = 50854, upload-time = "2025-09-22T12:12:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/de/aa/4289e51a3ade85feffd90cd8f343bf622c89d30327e02fe8cea5d0cca842/aiocsv-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce1cca5da676c55ddda624a4afcb1db9874897cac0d029e680cf114779daef19", size = 51022, upload-time = "2025-09-22T12:12:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/660f7683716c790034ea04e90eafb0e6ce8419a58e8b991b66a9825e8a2c/aiocsv-1.4.0-cp314-cp314-win32.whl", hash = "sha256:c926ac5dd442bd390a2fbae20c5207aabf275374de15359f37bcddaa60bfaedc", size = 29421, upload-time = "2025-09-22T12:12:53.977Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/9054d92876a35e8a48fcd7164dd75577410b8080d25790b3bf47766674be/aiocsv-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7a703643191b742d67d4e047dcc4b2314d2914f03879732a81139c62784f278", size = 30247, upload-time = "2025-09-22T12:12:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/67a8de91e8b2f08dbdadfb8fd9b1bf84530780c6f94db548b925993d37d1/aiocsv-1.4.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b78c992cf58ca1808182490b928261a11028d6b0cf6940a641ddd74ba8acccf2", size = 27773, upload-time = "2025-09-22T12:12:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0a/4d6cc89dcc3812525e63ccd1451e047de20328c7db58b05b3e3f36b3cab5/aiocsv-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4199dc97fbfcc6757c7de3e300f1c9de3736adf7700b009bd8795c6e708f855f", size = 28184, upload-time = "2025-09-22T12:12:56.542Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/f5467a1352f1d92391078724da333361481e9cfb400338d27e17665d03d8/aiocsv-1.4.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:84c7cf52ec30b7a78beec457c234defb837c970b4fbbcac113ddc769e533891c", size = 57063, upload-time = "2025-09-22T12:12:57.234Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2b/338acbc82536d79630614eda16b5605cea7287c2af49fcf172cdadb1d645/aiocsv-1.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b3075b482c7f419262b994f25827da5d917fb974b725d75f84b885eb634832", size = 58453, upload-time = "2025-09-22T12:12:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/6c3b1907f7bf1faac67c4de07b336ce4e4be35e904431afa909b7f66701d/aiocsv-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71756714498cae7775bd2f58b44d66d20d59be99b3c6231ba880282fe0bf0524", size = 56605, upload-time = "2025-09-22T12:12:58.749Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f0/1cf5a1a51157238e44e3ffa314d3b7d71e990adc9c94363bd63e833026b9/aiocsv-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c72822d9e405bef16a6c9aa06940c8671fc76bea67d13dad8d5766c5d2033dd", size = 56174, upload-time = "2025-09-22T12:12:59.52Z" }, + { url = "https://files.pythonhosted.org/packages/65/7f/e3bff58fcb1636ae49822c56c460463e11965837662ee9d1f092d51ba230/aiocsv-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:036433ef0e512921c265d5ace346748a60d8bfdd0b09bac558b8682f654f5018", size = 30000, upload-time = "2025-09-22T12:13:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b7/cc8ae7c1d3fe815078368d03f73019ae92c55b5f20614fa4e5e469dc8d8a/aiocsv-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5f655da021fb32303f676f22c643d7348788619120e864e22de92c9421c7eaa", size = 30938, upload-time = "2025-09-22T12:13:01.027Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "deepdiff" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684, upload-time = "2026-02-23T21:34:38.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053, upload-time = "2026-02-23T21:34:36.418Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "pybotx" +version = "0.77.0" +source = { editable = "." } +dependencies = [ + { name = "aiocsv" }, + { name = "aiofiles" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "mypy-extensions" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "tenacity" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "deepdiff" }, + { name = "factory-boy" }, + { name = "fastapi" }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "prometheus-client" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "requests" }, + { name = "respx" }, + { name = "ruff" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiocsv", specifier = ">=1.4.0,<2" }, + { name = "aiofiles", specifier = ">=25.1.0,<26" }, + { name = "httpcore", specifier = ">=1.0.9,<2" }, + { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "loguru", specifier = ">=0.7.3,<0.8" }, + { name = "mypy-extensions", specifier = ">=1.1.0,<2" }, + { name = "pydantic", specifier = ">=2.12.5,<3" }, + { name = "pyjwt", specifier = ">=2.11.0,<3" }, + { name = "tenacity", specifier = ">=9.1.2,<10" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.9.3,<2" }, + { name = "deepdiff", specifier = ">=8.6.1,<9" }, + { name = "factory-boy", specifier = ">=3.3.3,<4" }, + { name = "fastapi", specifier = ">=0.128.1,<0.129" }, + { name = "hypothesis", specifier = ">=6.151.5,<7" }, + { name = "mypy", specifier = ">=1.19.1,<2" }, + { name = "prometheus-client", specifier = ">=0.26.0" }, + { name = "pytest", specifier = ">=9.0.2,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3.0,<2" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8" }, + { name = "pytest-timeout", specifier = ">=2.4.0,<3" }, + { name = "pytest-xdist", specifier = ">=3.8.0,<4" }, + { name = "requests", specifier = ">=2.32.5,<3" }, + { name = "respx", specifier = ">=0.22.0,<0.23" }, + { name = "ruff", specifier = ">=0.15.0,<0.16" }, + { name = "starlette", specifier = ">=0.40,<0.51" }, + { name = "uvicorn", specifier = ">=0.40.0,<0.41" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]