commit f95223b88bb0702ef0ffb59021127c294219e2bc Author: Selectel ML Tokens Date: Wed Sep 2 16:38:03 2026 +0300 Initial import: selectel-ml-tui TUI for Selectel AI-router token usage diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1b49cd3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{md,toml,yml,yaml}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eee937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml + +# Secrets and local config +.env +config.toml +credentials + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba0735d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +Все заметные изменения проекта `selectel-ml-tui`. + +## [1.0.3] — 2026-08-19 + +Полноценный вывод данных из Gateway API ИИ-роутера (IAM). + +### Добавлено +- Парсинг реальной схемы Gateway API: + - `/overview` → потребление за месяц (сумма в нанорублях, запросы, токены + всего, input/output/cache read/cache write), бюджет роутера (лимит/расход/ + остаток); + - `/metrics` → метрики за 24 ч (доступность `success_rate_pct`, TTFT и + задержка в секундах→мс, запросы, токены) и `by_model` — по моделям + (запросы/доступность/TTFT); + - `/keys` → лимиты и расход по API-ключам (имя, префикс, статус, лимит, + потрачено, остаток). +- Новый экран **«API-ключи и лимиты»** (клавиша `k`) и панель бюджета на + главном экране. +- Модели: добавлены поля `requests`, `tokens_total`, `avg_latency_ms`; новые + модели `ApiKeyUsage`, `BudgetInfo`. + +### Исправлено +- «Потрачено за месяц» берётся из Gateway Overview (совпадает с вкладкой + «Обзор»), биллинг — фолбэк при отсутствии IAM. +- `SettingsScreen` не перезаписывает конфиг, если интервал не менялся. + +## [1.0.2] — 2026-08-19 + +Исправление отображения данных и прав на файлы конфигурации. + +### Исправлено +- **Сумма за месяц**: раньше `end` обрезался до начала текущего дня — сегодняшние + расходы не входили в сумму. Теперь `end` = текущий момент (naive datetime), + сумма включает текущий день. +- **История**: в историю записывалась месячная сумма как «точка дня». Теперь + сохраняется дневная стоимость (отдельно показывается «Сегодня»). +- **Токены/модели**: при отсутствии IAM (нет сервисного пользователя) поля + Input/Output/Cache больше не показывают вводящие в заблуждение нули — выводится + пояснение «Токены и модели — только через IAM». + +### Примечание +- Gateway API (токены по моделям, каталог моделей) требует IAM-токен сервисного + пользователя. В аккаунте нет сервисных пользователей — необходимо создать его + в панели (my.selectel.ru → IAM → Сервисные пользователи) и настроить через + `selectel-ml-tui config`. +- `credentials.json`/`config.toml` должны принадлежать текущему пользователю + (права 0600). Запуск от `root`/`sudo` создаёт файлы с владельцем root, из-за + чего обычный запуск падает с `PermissionError` — исправляется `chown`. + +## [1.0.1] — 2026-08-19 + +Исправление: крах при запуске, если в `config.toml` (или env +`SELCTEL_REFRESH_INTERVAL`) указан `refresh_interval < 10`. + +### Исправлено +- `load_settings()` больше не бросает `ValidationError` на некорректных + значениях: `refresh_interval` приводится к допустимому диапазону + (минимум 10 секунд), мусорные значения — к умолчанию 60. +- Мастер `selectel-ml-tui config` отклоняет интервал < 10 (повторный запрос). + +## [1.0.0] — 2026-08-19 + +Релиз, готовый к использованию. + +### Добавлено +- TUI-дашборд (Textual): потребление за месяц, метрики за 24 часа, таблица + «По моделям», статус `online`/`offline` в заголовке, автообновление. +- Экраны: **История** (бар-график + таблица по дням, периоды 7/30/90), + **Детали модели** (Enter в таблице моделей), **Настройки** (интервал + обновления, «Очистить кэш», пути, маскированный токен). +- API-клиенты: Billing Statistics (стоимость), Gateway API (токены/метрики), + IAM (получение и кэширование токена с автообновлением). +- Модели данных (pydantic) с валидацией и форматированием чисел. +- Локальный SQLite-кэш: снимки потребления/метрик/моделей, история по дням + (upsert), очистка старых записей (TTL 180 дней), экспорт CSV/JSON. +- CLI: `config` (интерактивный мастер), `run`, `export`, `doctor` + (диагностика окружения); `python -m selectel_ml_tui`. +- Адаптация под Ubuntu и macOS: скрипты установки, `scripts/run.sh`, + диагностика `TERM`/`LANG`/UTF-8, CI (GitHub Actions: ubuntu + macos). +- Упаковка: однофайловый бинарник через PyInstaller (`make build`). + +### Качество +- 89 тестов, покрытие 91% (порог 80%). +- ruff (flake8/isort/format), mypy strict — без ошибок. +- Подсказка при первом запуске без токена, FAQ и troubleshooting в README. + +[1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3 +[1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2 +[1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1 +[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfba297 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +PYTHON ?= $(shell command -v python3.13 || command -v python3.12 || command -v python3.11 || command -v python3.10 || command -v python3) +VENV ?= .venv +PIP := $(VENV)/bin/pip +PY := $(VENV)/bin/python + +.PHONY: help venv install run test lint typecheck format build clean install-ubuntu install-macos + +help: ## Показать доступные цели + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +venv: ## Создать виртуальное окружение + $(PYTHON) -m venv $(VENV) + +install: venv ## Установить проект в режиме разработки + $(PIP) install --upgrade pip + $(PIP) install -e ".[dev]" + +run: ## Запустить TUI-приложение + $(PY) -m selectel_ml_tui run + +test: ## Запустить тесты + $(PY) -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing + +lint: ## Проверить стиль кода (ruff) + $(VENV)/bin/ruff check . + +typecheck: ## Проверить типы (mypy) + $(VENV)/bin/mypy src + +format: ## Форматировать код (ruff format) + $(VENV)/bin/ruff format . + +build: ## Собрать однофайловый исполняемый файл (PyInstaller) + $(PIP) install pyinstaller + $(PY) -m PyInstaller --noconfirm --clean --onefile --console \ + --name selectel-ml-tui \ + --collect-all textual \ + --paths src \ + src/selectel_ml_tui/__main__.py + +install-ubuntu: ## Установить на Ubuntu (apt) + ./scripts/install_ubuntu.sh + +install-macos: ## Установить на macOS (Homebrew) + ./scripts/install_macos.sh + +clean: ## Удалить артефакты и виртуальное окружение + rm -rf $(VENV) .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov build dist *.egg-info \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a28fe1e --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# selectel-ml-tui + +TUI-приложение (Python 3.10+) для отображения потребления **ИИ-роутера Selectel** +прямо в терминале. Работает на Ubuntu (22.04+/24.04) и macOS (12+). + +## Возможности + +- Дашборд потребления за месяц (сумма, запросы, input/output/cache токены) +- Метрики за 24 часа (доступность, TTFT, задержка) — общие и по моделям +- Бюджет роутера (лимит/расход/остаток) и лимиты по API-ключам +- История потребления по дням с Unicode-бар-графиком (локальный SQLite-кэш) +- Детализация метрик конкретной модели (Enter в таблице моделей) +- Офлайн-режим: при недоступности API показывает последние данные из кэша +- Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) + +## Интерфейс + +``` +┌─ Selectel ML Tokens ──────────────── online · 1 186,73 ₽ ───────┐ +│ ┌─ Потребление за месяц ─┐ ┌─ Метрики за 24 часа ──────────────┐ │ +│ │ Сумма: 1 186,73 ₽ │ │ Доступность: 100,0% │ │ +│ │ Запросы: 1 096 │ │ TTFT: 3 670,0 мс │ │ +│ │ Токены: 482 749 246 │ │ Запросы: 1 097 │ │ +│ │ Input: 244 004 716 │ │ Input/Output/Cache: … │ │ +│ │ Output/Cache: … │ └───────────────────────────────────┘ │ +│ │ Бюджет: 2 000 ₽ │ │ +│ └────────────────────────┘ │ +│ По моделям │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Модель │ Запросы │ Доступность │ TTFT, мс │ Задержка, мс │ │ +│ │ deepseek-v4-pro │ 1028 │ 100,0% │ 3,69 │ 12,29 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ r — обновить h — история k — ключи s — настройки q — выход │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Экран **История** (**h**): бар-график стоимости по дням + таблица +(период 7/30/90 дней — кнопками). Экран **Ключи** (**k**): лимиты и расход по +API-ключам. Экран **Настройки** (**s**): интервал обновления, «Очистить кэш», +пути и маскированный токен. **Esc** — назад. + +## Горячие клавиши + +| Клавиша | Действие | +|---|---| +| `r` | Обновить данные | +| `h` | История потребления | +| `k` | API-ключи и лимиты | +| `s` | Настройки | +| `q` | Выход | +| `Esc` | Назад (с экрана истории/настроек/модели) | +| `Enter` / клик | Детализация метрик модели (в таблице «По моделям») | + +На macOS горячие клавиши — обычные символьные (`r/h/s/q`); Option/Command +зарезервированы системой и не используются. + +## Требования + +- Python 3.10+ +- Ubuntu 22.04+/24.04, macOS 12+ +- Терминал с поддержкой цвета и UTF-8 (GNOME Terminal, Konsole, tmux, + Terminal.app, iTerm2) + +## Источники данных + +- **Billing Statistics API** (`https://api.selectel.ru`, заголовок `X-Token`): + стоимость потребления ИИ-роутера (провайдер `aig`) по дням/часам. Параметры + `start`/`end` принимаются в полном ISO-формате (`YYYY-MM-DDTHH:MM:SS.000`), + короткие даты дополняются клиентом автоматически. Значение `value` в копейках. +- **Gateway API ИИ-роутера** (`/aig/gateway/v1/*`, IAM-токен проекта + `X-Project-Id`): + токены, метрики, каталог моделей. +- **IAM-авторизация**: `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens` + (логин/пароль сервисного пользователя, scope project, TTL 24 ч, кэш с + автообновлением за 5 минут до истечения). + +## Получение токенов (панель Selectel) + +- **Статический токен** (billing-метрики): панель `my.selectel.ru` → профиль → + **Доступ** → **API-ключи**. Вставьте токен в мастер настройки. +- **IAM-токен** (gateway: сумма/токены/модели/ключи): нужен **сервисный + пользователь**. Создайте его в `my.selectel.ru` → **IAM** → **Сервисные + пользователи** (имя без email, например `ml-monitor`), назначьте роль на + нужный ML-проект. Затем в `selectel-ml-tui config` укажите имя сервисного + пользователя, его пароль, `project_id` и `domain_id` (номер аккаунта). + Без сервисного пользователя доступна только стоимость из billing. + +Подробнее об API: [Selectel ML / управление роутером](https://selectel.ru/services/ml/) — +внутренние API панели и документация по биллингу. + +## Установка + +```bash +make install # Ubuntu/macOS +# или +./scripts/install_ubuntu.sh # Ubuntu +./scripts/install_macos.sh # macOS +# или make install-ubuntu / make install-macos +``` + +Всё сводится к двум командам на каждой ОС: + +```bash +# Ubuntu +sudo apt-get update && sudo apt-get install -y python3 python3-venv python3-pip +pip install --user . # или ./scripts/install_ubuntu.sh +``` + +```bash +# macOS +brew install python@3.11 # или ./scripts/install_macos.sh +pip install --user . +``` + +## Запуск + +```bash +make run +# или +.venv/bin/selectel-ml-tui run +# или (с корректным TERM/LANG, подходит для tmux/ssh) +./scripts/run.sh run +``` + +CLI-точка входа: `selectel-ml-tui` (console_scripts), а также `python -m selectel_ml_tui`. + +При первом запуске без настроенных учётных данных выводится подсказка +(`selectel-ml-tui config`); `selectel-ml-tui doctor` предупреждает, что токен +не настроен. + +TUI-дашборд: +- **Dashboard**: потребление за месяц (сумма, токены), метрики за 24 часа, + таблица «По моделям»; статус в заголовке — `online`/`offline` +- **r** — обновить, **h** — история, **s** — настройки, **q** — выход +- Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- При недоступности API показывает последние данные из кэша (офлайн) +- **История** (**h**): таблица по дням из кэша + Unicode-бар-график стоимости, + выбор периода 7/30/90 дней (кнопки) +- **Модель** (Enter/клик в таблице моделей): детализация метрик конкретной модели +- **Настройки** (**s**): изменение интервала обновления (сохранение в конфиг), + «Очистить кэш», пути к конфигу/БД, маскированный токен +- Навигация: **Esc** — назад + +## Конфигурация + +```bash +selectel-ml-tui config # интерактивный мастер настройки +selectel-ml-tui config --path # показать пути конфигурации +selectel-ml-tui config --show # показать текущие настройки (секреты маскируются) +``` + +Настройки хранятся в `config.toml`, учётные данные (пароль сервисного +пользователя, статический токен) — в `credentials.json` в каталоге конфигурации +(права 0600). Каталог: Linux — `~/.config/selectel-ml-tui`, macOS — +`~/Library/Application Support/selectel-ml-tui`. + +Переменные окружения имеют приоритет над файлом конфигурации: + +- `SELCTEL_API_URL` — базовый URL API (вместо `api_base_url`) +- `SELCTEL_TOKEN` — статический токен (вместо `static_token`) +- `SELCTEL_SERVICE_USER`, `SELCTEL_PASSWORD` — сервисный пользователь +- `SELCTEL_PROJECT_ID`, `SELCTEL_DOMAIN_ID` — проект и аккаунт +- остальные поля: `SELCTEL_API_BASE_URL`, `SELCTEL_REFRESH_INTERVAL`, `SELCTEL_ROUTER_ID`, … + +## Локальный кэш и история + +Данные сохраняются в SQLite-базу (`cache.db`) в каталоге данных платформы +(Linux: `~/.local/share/selectel-ml-tui`, macOS: +`~/Library/Application Support/selectel-ml-tui`) при каждом успешном обновлении: +- `consumption_snapshots` — снимки потребления (сумма, токены) +- `metrics_snapshots` — снимки общих метрик +- `model_metrics` — метрики по моделям +- `history` — точки истории по периодам (upsert, без дублей) + +При недоступности API TUI показывает «офлайн» и последние данные из кэша. +Старые записи удаляются автоматически (TTL 180 дней). + +Экспорт истории: `selectel-ml-tui export --format json|csv -o <файл>`. + +## Платформы (Ubuntu и macOS) + +Диагностика окружения: `selectel-ml-tui doctor` — проверяет Python (>=3.10), +`TERM`, `LANG`, кодировку UTF-8, выводит пути к конфигурации и данным. + +Отличия платформ: + +| | Ubuntu 22.04+/24.04 | macOS 12+ | +|---|---|---| +| Python | python3.10+ (apt: `python3 python3-venv python3-pip`) | Homebrew `python@3.11`+ | +| Каталог конфигурации | `~/.config/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` | +| Каталог данных | `~/.local/share/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` | +| Права доступа | 0700/0600 (по умолчанию) | то же; доступ на чтение к `Application Support` | +| Терминалы | GNOME Terminal, Konsole, tmux | Terminal.app, iTerm2 | +| Клавиши | — | hotkeys через Ctrl/символы (r/h/s/q); Option/Command зарезервированы системой | + +Требования к окружению терминала: +- `TERM` должен указывать на цветной терминал (`xterm-256color`, `tmux-256color`). + В `tmux`/`screen` задаётся автоматически; `scripts/run.sh` подставляет + `xterm-256color`, если переменная не задана. +- `LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), иначе + возможны сбои отображения русского текста и граничных символов (▌▀█). + +CI: GitHub Actions (`ubuntu-latest` + `macos-latest`, Python 3.11/3.12) — ruff, +mypy, pytest+coverage, doctor. + +## Упаковка (однофайловый исполняемый файл) + +Опционально: сборка автономного бинарника через PyInstaller. + +```bash +make build # или ./scripts/build_ubuntu.sh / build_macos.sh +./dist/selectel-ml-tui doctor # проверка +``` + +Артефакт не зависит от установленного Python и venv (собирается на целевой ОС). + +История версий: [CHANGELOG.md](CHANGELOG.md). Финальный чек-лист тестирования: +[docs/FINAL_CHECKLIST.md](docs/FINAL_CHECKLIST.md). + +## Разработка + +```bash +make test # pytest + coverage (порог 80%) +make lint # ruff (flake8/isort/format) +make typecheck # mypy (strict) +make format # ruff format +``` + +- Unit-тесты: config (приоритет env), storage, models (парсинг/форматирование), + API-клиент (httpx.MockTransport: успех, 401, 429, 500, timeout, сетевые ошибки) +- Интеграционный smoke-тест TUI: headless-запуск приложения через `App.run_test()` +- Статический анализ настроен в `pyproject.toml` (`[tool.ruff]`, `[tool.mypy]`, + `fail_under = 80`). + +## FAQ и troubleshooting + +**«Учётные данные не настроены» при запуске** +Выполните `selectel-ml-tui config` — мастер настройки запросит токен +(и опционально сервисного пользователя для метрик моделей). Проверка: +`selectel-ml-tui doctor` → `[OK] token`. + +**Ошибки авторизации (401 / 403)** +- Статический токен истёк или неверен — получите новый в панели Selectel + (профиль → API-токены) и обновите через `selectel-ml-tui config`. +- IAM-доступ: сервисный пользователь должен иметь роль *reader* на нужном + ML-проекте; проверьте `project_id` и `domain_id` (аккаунт). У IAM-токена + TTL 24 ч — приложение обновляет его автоматически (за 5 минут до истечения). + +**В дашборде нет метрик по моделям (Input/Output/Cache отсутствуют)** +Gateway-данные (токены по моделям, каталог моделей) требуют IAM-токена +сервисного пользователя. Если сервисный пользователь не создан, доступна +только стоимость (billing) — на дашборде выводится пояснение +«Токены и модели — только через IAM». Создать сервисного пользователя: +my.selectel.ru → IAM → Сервисные пользователи (с областью доступа на нужный +проект), затем `selectel-ml-tui config`. + +**PermissionError при запуске (`credentials.json` не читается)** +Файлы конфигурации должны принадлежать текущему пользователю (права 0600). +Если запускать приложение от `root`/`sudo`, файлы создаются с владельцем root, +и обычный запуск падает. Исправление (macOS/Linux): +`sudo chown -R $USER ~/Library/Application\ Support/selectel-ml-tui` +(на Linux путь `~/.config/selectel-ml-tui`). Не запускайте приложение от root. + +**Ошибка 422 при запросе данных** +API принимает только полные ISO-таймстампы (`YYYY-MM-DDTHH:MM:SS.000`). +Приложение формирует их самостоятельно; если ошибка повторяется — проверьте +часовой пояс (`doctor` → `LANG`/`locale`). + +**«Краказябры»/битые символы в терминале** +`LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), `TERM` — +цветной (`xterm-256color`). Запуск через `./scripts/run.sh run` подставит +значения автоматически (полезно в tmux/ssh). + +**Офлайн-режим** +При недоступности API в заголовке появляется `offline`, а дашборд показывает +последние снимки из SQLite-кэша. Данные обновятся автоматически при +восстановлении сети (клавиша `r` — вручную). + +**«Нет данных для графика» в истории** +Кэш пуст — данные появляются после первого успешного обновления (каждая +загрузка сохраняет точку за день). Очистить кэш можно в экране «Настройки» +или через `export` (не очищает). \ No newline at end of file diff --git a/docs/FINAL_CHECKLIST.md b/docs/FINAL_CHECKLIST.md new file mode 100644 index 0000000..70a6332 --- /dev/null +++ b/docs/FINAL_CHECKLIST.md @@ -0,0 +1,39 @@ +# Финальный чек-лист тестирования (релиз 1.0.0) + +Прогон на каждой ОС (Ubuntu 22.04/24.04 и macOS 12+). Автоматические проверки +выполняются в CI (GitHub Actions: `ubuntu-latest`, `macos-latest`); ручные — +в терминале. + +## Автоматически (CI: `make test lint typecheck`) + +- [x] Установка: `python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"` +- [x] CLI entry point: `selectel-ml-tui --version` → 1.0.0 +- [x] Первый запуск без токена: подсказка + exit code 1 +- [x] Линт: `ruff check src tests` +- [x] Типы: `mypy src` (strict) +- [x] Тесты: `pytest --cov=selectel_ml_tui` (порог 80%) +- [x] Диагностика: `selectel-ml-tui doctor` + +## Ручные проверки (обе ОС) + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | `./scripts/install_ubuntu.sh` / `install_macos.sh` | установка без ошибок | +| 2 | `selectel-ml-tui config` | мастер сохраняет токен (права 0600) | +| 3 | `selectel-ml-tui run` | запуск дашборда, статус `online` | +| 4 | Данные: сумма за месяц | значение > 0, формат `1 234,56 ₽` | +| 5 | Метрики/модели | таблица «По моделям», Enter → детали | +| 6 | Кэш | история (`h`) показывает точки за дни | +| 7 | Экспорт | `export --format csv/json` создаёт файл | +| 8 | Офлайн (отключить сеть, `r`) | заголовок `offline`, данные из кэша | +| 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки | +| 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает | +| 11 | Русский текст | корректное отображение (UTF-8) | +| 12 | `doctor` | все `[OK]`, пути существуют | + +## Отметка выполнения + +- Ubuntu: ____ +- macOS: ____ + +Комментарии/проблемы: ____ \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..0bef74e --- /dev/null +++ b/mypy.ini @@ -0,0 +1,13 @@ +[mypy] +python_version = 3.10 +strict = true +warn_unused_configs = true +check_untyped_defs = true +disallow_any_generics = true +warn_redundant_casts = true +warn_unused_ignores = true + +[[mypy.selectel_ml_tui.ui.*]] +# Textual widget internals use dynamic attributes; relax strictness there. +check_untyped_defs = false +disallow_any_generics = false \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ae965c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "selectel-ml-tui" +version = "1.0.3" +description = "TUI-приложение для отображения потребления ИИ-роутера Selectel" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Selectel ML Tokens" }] +keywords = ["selectel", "ai-router", "tui", "textual", "billing"] +classifiers = [ + "Environment :: Console", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Topic :: Terminals :: Terminal Emulators/X Terminals", +] + +dependencies = [ + "textual>=2.0", + "httpx>=0.27", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "python-dotenv>=1.0", + "click>=8.1", + "platformdirs>=4.0", + "tomli>=2.0", + "tomli-w>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", + "ruff>=0.5", + "mypy>=1.10", +] +build = ["pyinstaller>=6.0"] + +[project.scripts] +selectel-ml-tui = "selectel_ml_tui.cli:main" + +[project.urls] +Homepage = "https://selectel.ru" + +[tool.hatch.build.targets.wheel] +packages = ["src/selectel_ml_tui"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.coverage.run] +branch = true +source = ["selectel_ml_tui"] + +[tool.coverage.report] +show_missing = true +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] + +[tool.ruff.lint.isort] +known-first-party = ["selectel_ml_tui"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src"] +show_error_codes = true +warn_unused_ignores = true \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..31c2eec --- /dev/null +++ b/ruff.toml @@ -0,0 +1,13 @@ +target-version = "py310" + +[lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = [] + +[lint.isort] +known-first-party = ["selectel_ml_tui"] + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" \ No newline at end of file diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh new file mode 100755 index 0000000..6f70660 --- /dev/null +++ b/scripts/build_macos.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Сборка однофайлового исполняемого файла selectel-ml-tui для macOS. +set -euo pipefail + +cd "$(dirname "$0")/.." +make build +echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)" \ No newline at end of file diff --git a/scripts/build_ubuntu.sh b/scripts/build_ubuntu.sh new file mode 100755 index 0000000..7220a19 --- /dev/null +++ b/scripts/build_ubuntu.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Сборка однофайлового исполняемого файла selectel-ml-tui для Linux (Ubuntu). +set -euo pipefail + +cd "$(dirname "$0")/.." +make build +echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)" \ No newline at end of file diff --git a/scripts/install_macos.sh b/scripts/install_macos.sh new file mode 100644 index 0000000..952476e --- /dev/null +++ b/scripts/install_macos.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Установка selectel-ml-tui на macOS (Homebrew) +set -euo pipefail + +brew install python@3.11 + +cd "$(dirname "$0")/.." +make install + +echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run" \ No newline at end of file diff --git a/scripts/install_ubuntu.sh b/scripts/install_ubuntu.sh new file mode 100644 index 0000000..30773fb --- /dev/null +++ b/scripts/install_ubuntu.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Установка selectel-ml-tui на Ubuntu (22.04+) +set -euo pipefail + +sudo apt-get update +sudo apt-get install -y python3 python3-venv python3-pip + +cd "$(dirname "$0")/.." +make install + +echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run" \ No newline at end of file diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..a96a7c4 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Запуск selectel-ml-tui с корректным окружением терминала. +# Использование: scripts/run.sh [doctor|run|...] +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Подстраховка для минималистичных терминалов (tmux/screen, ssh). +if [[ -z "${TERM:-}" ]]; then + export TERM=xterm-256color +fi +if [[ -z "${LANG:-}" ]]; then + export LANG=C.UTF-8 +fi + +if command -v uv >/dev/null 2>&1; then + exec uv run selectel-ml-tui "$@" +elif command -v selectel-ml-tui >/dev/null 2>&1; then + exec selectel-ml-tui "$@" +else + echo "selectel-ml-tui не найден. Установите через 'pip install .' или 'uv sync'" >&2 + exit 1 +fi \ No newline at end of file diff --git a/selectel-ml-tui.spec b/selectel-ml-tui.spec new file mode 100644 index 0000000..298604f --- /dev/null +++ b/selectel-ml-tui.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = [] +tmp_ret = collect_all('textual') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['src/selectel_ml_tui/__main__.py'], + pathex=['src'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='selectel-ml-tui', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/selectel_ml_tui/__init__.py b/src/selectel_ml_tui/__init__.py new file mode 100644 index 0000000..31e995f --- /dev/null +++ b/src/selectel_ml_tui/__init__.py @@ -0,0 +1,3 @@ +"""TUI-приложение для отображения потребления ИИ-роутера Selectel.""" + +__version__ = "1.0.3" diff --git a/src/selectel_ml_tui/__main__.py b/src/selectel_ml_tui/__main__.py new file mode 100644 index 0000000..ba2d426 --- /dev/null +++ b/src/selectel_ml_tui/__main__.py @@ -0,0 +1,4 @@ +from selectel_ml_tui.cli import main + +if __name__ == "__main__": + main() diff --git a/src/selectel_ml_tui/api/__init__.py b/src/selectel_ml_tui/api/__init__.py new file mode 100644 index 0000000..d4c24f6 --- /dev/null +++ b/src/selectel_ml_tui/api/__init__.py @@ -0,0 +1,11 @@ +"""Клиенты API Selectel: billing (стоимость) и gateway (ИИ-роутер).""" + +from selectel_ml_tui.api.billing import BillingApiError, BillingClient +from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient + +__all__ = [ + "BillingApiError", + "BillingClient", + "GatewayApiError", + "GatewayClient", +] diff --git a/src/selectel_ml_tui/api/billing.py b/src/selectel_ml_tui/api/billing.py new file mode 100644 index 0000000..0280e46 --- /dev/null +++ b/src/selectel_ml_tui/api/billing.py @@ -0,0 +1,132 @@ +"""Клиент Billing Statistics API Selectel (провайдер aig). + +Аутентификация: статический токен в заголовке X-Token. +Данные: стоимость потребления ИИ-роутера по дням/часам. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, cast + +import httpx + +from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError +from selectel_ml_tui.models import ConsumptionItem + +BillingApiError = SelectelApiError + + +def normalize_dt(value: str) -> str: + """Привести дату к формату, принимаемому Billing API. + + Billing API ожидает полный ISO-таймстамп (например + ``2026-08-01T00:00:00.000``); короткая дата дополняется. + """ + if "T" in value: + return value + return f"{value}T00:00:00.000" + + +class BillingClient(BaseApiClient): + """Клиент /v1/cloud_billing/statistic/* для провайдера aig.""" + + PROVIDER = "aig" + + def __init__( + self, + api_base_url: str, + static_token: str, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Any = None, + ) -> None: + super().__init__( + timeout=timeout, + max_retries=max_retries, + transport=transport, + sleep=sleep, + ) + self._base_url = api_base_url.rstrip("/") + self._token = static_token + + def _headers(self) -> dict[str, str]: + return {"X-Token": self._token, "Content-Type": "application/json"} + + async def _get( + self, path: str, params: dict[str, str] | None = None + ) -> dict[str, Any]: + url = f"{self._base_url}{path}" + response = await self.request( + "GET", url, params=params, headers=self._headers() + ) + data = response.json() + if not isinstance(data, dict) or data.get("status") != "success": + raise BillingApiError(f"Billing API: {data}") + return data + + async def get_consumption( + self, + start: str, + end: str, + locale: str = "ru", + group_type: str = "metric", + period_group_type: str = "day", + ) -> list[ConsumptionItem]: + """Получить потребление за период. + + group_type: project | object | metric | project_metric | region_metric + period_group_type: hour | day | week | month | year | all + """ + data = await self._get( + "/v1/cloud_billing/statistic/consumption", + params={ + "provider_keys": self.PROVIDER, + "start": normalize_dt(start), + "end": normalize_dt(end), + "locale": locale, + "group_type": group_type, + "period_group_type": period_group_type, + }, + ) + return [ConsumptionItem.model_validate(item) for item in data.get("data", [])] + + async def get_consumption_history( + self, + start: str, + end: str, + group: str = "day", + ) -> list[ConsumptionItem]: + """История потребления по дням/часам за период. + + group: hour | day | week | month | year | all + """ + return await self.get_consumption( + start, end, period_group_type=group, group_type="metric" + ) + + async def get_summary( + self, + start: str, + end: str, + group_type: str = "metric", + period_group_type: str = "month", + ) -> Decimal: + """Суммарная стоимость за период в копейках (value).""" + data = await self._get( + "/v1/cloud_billing/statistic/summary_stats", + params={ + "provider_keys": self.PROVIDER, + "start": normalize_dt(start), + "end": normalize_dt(end), + "group_type": group_type, + "period_group_type": period_group_type, + "items_count_period_group_type": "all", + }, + ) + rows = data.get("data", []) + total: Decimal = Decimal("0") + for row in cast(list[dict[str, Any]], rows): + total += Decimal(str(row.get("value", 0))) + return total diff --git a/src/selectel_ml_tui/api/client.py b/src/selectel_ml_tui/api/client.py new file mode 100644 index 0000000..b609ed8 --- /dev/null +++ b/src/selectel_ml_tui/api/client.py @@ -0,0 +1,93 @@ +"""Базовый HTTP-слой для клиентов API Selectel. + +httpx.AsyncClient с таймаутами и лимитом соединений; единая обработка ошибок: +- 429 (rate limit) и 5xx — повтор с экспоненциальной паузой; +- сетевые ошибки httpx — повтор; +- остальные 4xx — исключение SelectelApiError с понятным текстом. + +Слой отделён от TUI и тестируется моками httpx.MockTransport. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +import httpx + + +class SelectelApiError(RuntimeError): + """Ошибка обращения к API Selectel. + + Атрибут ``status_code`` заполнен для HTTP-ошибок (None для сетевых). + """ + + def __init__(self, message: str, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class BaseApiClient: + """Базовый клиент: транспорт, ретраи, понятные ошибки.""" + + def __init__( + self, + *, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + ) -> None: + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + self._client = httpx.AsyncClient( + timeout=timeout, + limits=limits, + transport=transport, + ) + self._max_retries = max_retries + self._sleep = sleep if sleep is not None else asyncio.sleep + + async def aclose(self) -> None: + """Закрыть транспортные соединения.""" + await self._client.aclose() + + async def request( + self, + method: str, + url: str, + *, + params: dict[str, str] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + """Выполнить запрос с повторами и понятной обработкой ошибок.""" + last_exc: SelectelApiError | None = None + for attempt in range(self._max_retries + 1): + try: + response = await self._client.request( + method, url, params=params, json=json, headers=headers + ) + except httpx.HTTPError as exc: + if attempt < self._max_retries: + await self._sleep(self._backoff_seconds(attempt)) + continue + raise SelectelApiError(f"Ошибка сети: {exc}") from exc + + if response.status_code < 400: + return response + + retriable = response.status_code == 429 or response.status_code >= 500 + if retriable and attempt < self._max_retries: + await self._sleep(self._backoff_seconds(attempt)) + continue + + raise SelectelApiError( + f"HTTP {response.status_code}: {response.text[:300]}", + status_code=response.status_code, + ) + raise last_exc if last_exc else SelectelApiError("Запрос не выполнен") + + @staticmethod + def _backoff_seconds(attempt: int) -> float: + return float(0.5 * (2**attempt)) diff --git a/src/selectel_ml_tui/api/gateway.py b/src/selectel_ml_tui/api/gateway.py new file mode 100644 index 0000000..4b4d80f --- /dev/null +++ b/src/selectel_ml_tui/api/gateway.py @@ -0,0 +1,98 @@ +"""Клиент Gateway API ИИ-роутера Selectel (/aig/gateway/v1/*). + +Аутентификация: IAM-токен проекта (X-Auth-Token) + X-Project-Id и др. +Данные: потребление (overview), метрики 24ч, модели, ключи. + +Токен берётся из IamTokenManager; при HTTP 401 выполняется его +автоматическое обновление и один повтор запроса. +""" + +from __future__ import annotations + +from typing import Any, cast + +import httpx + +from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError +from selectel_ml_tui.auth import IamTokenManager +from selectel_ml_tui.models import RouterMetrics, RouterOverview + +GatewayApiError = SelectelApiError + + +class GatewayClient(BaseApiClient): + """Клиент /aig/gateway/v1/*.""" + + def __init__( + self, + api_base_url: str, + token_provider: IamTokenManager, + project_id: str, + domain_id: str, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Any = None, + ) -> None: + super().__init__( + timeout=timeout, + max_retries=max_retries, + transport=transport, + sleep=sleep, + ) + self._base_url = api_base_url.rstrip("/") + self._token_provider = token_provider + self._project_id = project_id + self._domain_id = domain_id + + def _headers(self, token: str) -> dict[str, str]: + return { + "X-Auth-Token": token, + "X-Project-Id": self._project_id, + "X-Project-Domain-Id": self._domain_id, + "X-Project-Domain-Name": self._domain_id, + "X-Roles": "reader", + "Content-Type": "application/json", + } + + async def _get(self, path: str) -> dict[str, Any]: + url = f"{self._base_url}{path}" + token = await self._token_provider.get_token() + try: + response = await self.request("GET", url, headers=self._headers(token)) + except SelectelApiError as exc: + if exc.status_code == 401: + self._token_provider.invalidate() + token = await self._token_provider.get_token() + response = await self.request("GET", url, headers=self._headers(token)) + else: + raise + data = response.json() + if isinstance(data, dict) and data.get("error"): + raise GatewayApiError(f"Gateway API: {data['error']}") + return cast(dict[str, Any], data) + + async def list_gateways(self) -> list[dict[str, Any]]: + """Список ИИ-роутеров.""" + data = await self._get("/aig/gateway/v1/gateways") + return cast(list[dict[str, Any]], data.get("data", data.get("gateways", []))) + + async def get_overview(self, router_id: str) -> RouterOverview: + """Потребление роутера (вкладка «Обзор»).""" + data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/overview") + return RouterOverview(raw=data) + + async def get_metrics(self, router_id: str) -> RouterMetrics: + """Метрики роутера за 24 часа (вкладка «Метрики»).""" + data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/metrics") + return RouterMetrics(raw=data) + + async def list_models(self) -> list[dict[str, Any]]: + """Каталог моделей.""" + data = await self._get("/aig/gateway/v1/models") + return cast(list[dict[str, Any]], data.get("data", data.get("models", []))) + + async def list_keys(self) -> list[dict[str, Any]]: + """Список API-ключей роутера с лимитами.""" + data = await self._get("/aig/gateway/v1/keys") + return cast(list[dict[str, Any]], data.get("data", data.get("keys", []))) diff --git a/src/selectel_ml_tui/auth.py b/src/selectel_ml_tui/auth.py new file mode 100644 index 0000000..bcf2bb9 --- /dev/null +++ b/src/selectel_ml_tui/auth.py @@ -0,0 +1,137 @@ +"""IAM-авторизация Selectel. + +Получение и кэширование IAM-токена проекта через +POST {identity_url}/auth/tokens (сервисный пользователь, scope project). +Время жизни токена — 24 часа; IamTokenManager автоматически обновляет токен +при приближении к истечению (запас 5 минут). +""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from selectel_ml_tui.config import Credentials, Settings + + +class IamToken(BaseModel): + """IAM-токен с временем жизни.""" + + model_config = ConfigDict(populate_by_name=True) + + value: str = Field(alias="X-Subject-Token") + issued_at: dt.datetime = Field( + default_factory=lambda: dt.datetime.now(dt.timezone.utc) + ) + expires_at: dt.datetime = Field(alias="expires_at") + + +class IamAuthError(RuntimeError): + """Ошибка получения IAM-токена.""" + + +def _parse_expires_at(raw: str) -> dt.datetime: + """Распарсить expires_at из ответа IAM (ISO8601, возможно с 'Z').""" + text = raw.replace("Z", "+00:00") + return dt.datetime.fromisoformat(text) + + +class IamClient: + """Клиент получения IAM-токена проекта.""" + + def __init__( + self, + settings: Settings, + credentials: Credentials, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._settings = settings + self._credentials = credentials + self._transport = transport + + async def get_token(self) -> IamToken: + """Получить IAM-токен проекта для аккаунта/домена.""" + if not self._credentials.service_user or not self._credentials.password: + raise IamAuthError( + "Не заданы логин/пароль сервисного пользователя. " + "Выполните: selectel-ml-tui config" + ) + body = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": self._credentials.service_user, + "domain": {"name": self._credentials.domain_id}, + "password": self._credentials.password, + } + }, + }, + "scope": {"project": {"id": self._credentials.project_id}}, + } + } + url = f"{self._settings.identity_url}/auth/tokens" + try: + async with httpx.AsyncClient( + timeout=30, transport=self._transport + ) as client: + response = await client.post(url, json=body) + except httpx.HTTPError as exc: + raise IamAuthError(f"Ошибка сети при обращении к {url}: {exc}") from exc + if response.status_code not in (200, 201): + raise IamAuthError( + f"IAM: HTTP {response.status_code}: {response.text[:300]}" + ) + token = cast(str | None, response.headers.get("X-Subject-Token")) + if not token: + raise IamAuthError("IAM: отсутствует заголовок X-Subject-Token в ответе") + data = response.json() + expires_raw = ( + data.get("token", {}).get("expires_at") if isinstance(data, dict) else None + ) + if not isinstance(expires_raw, str): + raise IamAuthError("IAM: отсутствует поле token.expires_at в ответе") + return IamToken.model_validate( + { + "X-Subject-Token": token, + "expires_at": _parse_expires_at(expires_raw), + } + ) + + +class IamTokenManager: + """Кэширует IAM-токен и обновляет его по истечении TTL. + + Позволяет разделить получение токена и его использование клиентами. + """ + + SAFETY_MARGIN = dt.timedelta(minutes=5) + + def __init__(self, client: IamClient) -> None: + self._client = client + self._token: str | None = None + self._expires_at: dt.datetime | None = None + + def invalidate(self) -> None: + """Сбросить кэш (например, после HTTP 401).""" + self._token = None + self._expires_at = None + + def is_valid(self) -> bool: + if self._token is None or self._expires_at is None: + return False + now = dt.datetime.now(dt.timezone.utc) + return self._expires_at > now + self.SAFETY_MARGIN + + async def get_token(self) -> str: + """Вернуть валидный токен, при необходимости обновив его.""" + if self.is_valid(): + return cast(str, self._token) + iam_token = await self._client.get_token() + self._token = iam_token.value + self._expires_at = iam_token.expires_at + return iam_token.value diff --git a/src/selectel_ml_tui/cli.py b/src/selectel_ml_tui/cli.py new file mode 100644 index 0000000..186ea90 --- /dev/null +++ b/src/selectel_ml_tui/cli.py @@ -0,0 +1,201 @@ +"""CLI-интерфейс selectel-ml-tui.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import click + +from selectel_ml_tui import __version__ +from selectel_ml_tui.config import ( + Credentials, + Settings, + config_dir, + data_dir, + load_credentials, + load_settings, + save_credentials, + save_settings, +) + + +def _mask(value: str) -> str: + """Замаскировать секрет: показать первые 4 символа и длину.""" + if not value: + return "(не задан)" + return f"{value[:4]}…({len(value)})" + + +def _print_config(settings: Settings, credentials: Credentials) -> None: + click.echo(f"API base URL: {settings.api_base_url}") + click.echo(f"Identity URL: {settings.identity_url}") + click.echo(f"Refresh interval: {settings.refresh_interval} c") + router_id = settings.router_id or "(не задан, будет выбран первый)" + click.echo(f"Router id: {router_id}") + if settings.region: + click.echo(f"Region: {settings.region}") + click.echo(f"Service user: {credentials.service_user or '(не задан)'}") + click.echo(f"Project id: {credentials.project_id or '(не задан)'}") + click.echo(f"Domain id: {credentials.domain_id or '(не задан)'}") + click.echo(f"Static token: {_mask(credentials.static_token)}") + click.echo(f"Password: {_mask(credentials.password)}") + + +def _prompt_str( + label: str, + current: str, + hide_input: bool = False, + default_note: str = "(Enter — оставить)", +) -> str: + prompt = f"{label} [{current or 'пусто'}] {default_note}: " + value = click.prompt(prompt, default="", hide_input=hide_input, show_default=False) + if value == "": + return current + return cast(str, value) + + +@click.group() +@click.version_option(__version__, prog_name="selectel-ml-tui") +def main() -> None: + """TUI-приложение для отображения потребления ИИ-роутера Selectel.""" + + +@main.command() +@click.option("--path", is_flag=True, help="Показать пути к конфигурации и выйти") +@click.option("--show", is_flag=True, help="Показать текущие настройки и выйти") +@click.option( + "--non-interactive", + is_flag=True, + help="Не запрашивать ввод (эквивалентно --show)", +) +def config(path: bool, show: bool, non_interactive: bool) -> None: + """Настроить приложение (интерактивный мастер) или показать настройки.""" + if path: + click.echo(f"config dir: {config_dir()}") + click.echo(f"data dir: {data_dir()}") + return + + settings = load_settings() + credentials = load_credentials() + + if show or non_interactive: + _print_config(settings, credentials) + return + + click.echo("Настройка selectel-ml-tui. Пустые значения сохраняют текущие.") + settings.api_base_url = _prompt_str("API base URL", settings.api_base_url) + while True: + raw = _prompt_str( + "Интервал обновления (сек, min 10)", + str(settings.refresh_interval), + ) + try: + value = int(raw) + except ValueError: + click.echo("Ошибка: введите целое число секунд") + continue + if value < 10: + click.echo("Ошибка: интервал не может быть меньше 10 секунд") + continue + settings.refresh_interval = value + break + settings.router_id = _prompt_str( + "ID ИИ-роутера (необязательно)", settings.router_id + ) + settings.region = _prompt_str("Регион (необязательно)", settings.region) + + credentials.service_user = _prompt_str( + "Сервисный пользователь", credentials.service_user + ) + credentials.domain_id = _prompt_str( + "Номер аккаунта (domain)", credentials.domain_id + ) + credentials.project_id = _prompt_str("ID проекта (ML)", credentials.project_id) + credentials.password = _prompt_str( + "Пароль сервисного пользователя", + credentials.password, + hide_input=True, + ) + credentials.static_token = _prompt_str( + "Статический токен (X-Token)", + credentials.static_token, + hide_input=True, + ) + + save_settings(settings) + save_credentials(credentials) + click.echo(f"Сохранено: {config_dir()}") + _print_config(settings, credentials) + + +@main.command() +@click.option( + "--format", + "fmt", + type=click.Choice(["csv", "json"]), + default="json", + help="Формат экспорта", +) +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + default=None, + help="Путь к файлу экспорта (по умолчанию history.csv/json в каталоге данных)", +) +def export(fmt: str, output: Path | None) -> None: + """Экспортировать историю потребления в CSV/JSON.""" + from selectel_ml_tui.storage import Storage + + path = output if output is not None else data_dir() / f"history.{fmt}" + storage = Storage() + try: + if fmt == "csv": + storage.export_csv(path) + else: + storage.export_json(path) + finally: + storage.close() + click.echo(f"Экспортировано: {path}") + + +@main.command() +def doctor() -> None: + """Проверить окружение (ОС, терминал, кодировку) и пути.""" + from selectel_ml_tui.platform import check_environment, get_platform_info + + info = get_platform_info() + click.echo(f"ОС: {info.system}") + click.echo(f"Python: {info.python_version}") + failed = False + for check in check_environment(): + mark = "OK " if check.ok else "WARN" + failed = failed or not check.ok + click.echo(f"[{mark}] {check.name}: {check.detail}") + credentials = load_credentials() + has_token = bool(credentials.static_token or credentials.password) + hint = "не настроен — выполните: selectel-ml-tui config" + token_status = "настроен" if has_token else hint + click.echo(f"[{'OK ' if has_token else 'WARN'}] token: {token_status}") + click.echo(f"Config dir: {config_dir()}") + click.echo(f"Data dir: {data_dir()}") + if failed: + raise click.exceptions.Exit(1) + + +@main.command() +def run() -> None: + """Запустить TUI-интерфейс (дашборд).""" + from selectel_ml_tui.ui.app import run_app + + credentials = load_credentials() + if not credentials.static_token and not credentials.password: + msg = "Учётные данные не настроены. Выполните: selectel-ml-tui config" + click.echo(msg, err=True) + raise click.exceptions.Exit(1) + run_app() + + +if __name__ == "__main__": + main() diff --git a/src/selectel_ml_tui/config.py b/src/selectel_ml_tui/config.py new file mode 100644 index 0000000..c0b8697 --- /dev/null +++ b/src/selectel_ml_tui/config.py @@ -0,0 +1,206 @@ +"""Конфигурация приложения: настройки, пути, секреты. + +Поддерживается чтение переменных окружения с префиксом SELCTEL_ и файла +конфигурации config.toml. Секреты (пароль сервисного пользователя, +статический токен) хранятся отдельно в файле credentials (права 0600). +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +from platformdirs import user_config_dir, user_data_dir +from pydantic import BaseModel, Field, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict + +APP_NAME = "selectel-ml-tui" + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - Python 3.10 fallback + import tomli as tomllib + + +class Settings(BaseSettings): + """Несекретные настройки приложения. + + Источники: env (SELCTEL_*), затем config.toml. + """ + + model_config = SettingsConfigDict( + env_prefix="SELCTEL_", env_file=".env", extra="ignore" + ) + + api_base_url: str = Field( + default="https://api.selectel.ru", + description="Базовый URL API Selectel", + ) + identity_url: str = Field( + default="https://cloud.api.selcloud.ru/identity/v3", + description="URL сервиса IAM-авторизации", + ) + refresh_interval: int = Field( + default=60, + ge=10, + description="Интервал автообновления TUI, секунд", + ) + router_id: str = Field( + default="", + description="ID ИИ-роутера (если не указан, берётся первый из списка)", + ) + region: str = Field( + default="", + description="Регион/пул (необязательно, для совместимости)", + ) + + +class Credentials(BaseModel): + """Учётные данные для доступа к API.""" + + service_user: str = "" + password: str = "" + project_id: str = "" + domain_id: str = "" + static_token: str = "" + + +def config_dir() -> Path: + """Каталог конфигурации (Linux: ~/.config, macOS: ~/Library/Application Support).""" + return Path(user_config_dir(APP_NAME)) + + +def data_dir() -> Path: + """Каталог данных (Linux: ~/.local/share, macOS: ~/Library/Application Support).""" + return Path(user_data_dir(APP_NAME)) + + +def _config_file() -> Path: + return config_dir() / "config.toml" + + +def _credentials_file() -> Path: + return config_dir() / "credentials.json" + + +def load_settings() -> Settings: + """Загрузить настройки: env > config.toml > значения по умолчанию. + + Дополнительные env-алиасы: SELCTEL_API_URL (для api_base_url). + Некорректные значения (например, refresh_interval < 10 из старого + конфига) не приводят к ошибке: поле приводится к допустимому значению. + """ + base = _settings_from_env() + cfg = _read_config_file() + if not cfg: + settings = base + else: + # Поля, заданные через env, имеют приоритет над файлом конфигурации. + env_fields = { + name: value + for name, value in base.model_dump().items() + if name in base.model_fields_set + } + settings = _validate_settings({**cfg, **env_fields}) + + api_url = os.environ.get("SELCTEL_API_URL") + if api_url: + settings = settings.model_copy(update={"api_base_url": api_url}) + return settings + + +def _settings_from_env() -> Settings: + """Настройки из переменных окружения (устойчиво к некорректным значениям).""" + try: + return Settings() + except ValidationError: + # Некорректное значение в env (например, SELCTEL_REFRESH_INTERVAL=5): + # model_construct() не читает env и не валидирует — чистые дефолты. + return Settings.model_construct() + + +def _validate_settings(data: dict[str, Any]) -> Settings: + """Валидировать настройки; при ошибке починить и повторить.""" + try: + return Settings.model_validate(data) + except ValidationError: + return Settings.model_validate(_sanitize_settings(data)) + + +def _sanitize_settings(data: dict[str, Any]) -> dict[str, Any]: + """Привести некорректные значения к допустимым (refresh_interval >= 10).""" + repaired = dict(data) + interval = repaired.get("refresh_interval") + if interval is not None: + try: + repaired["refresh_interval"] = max(10, int(interval)) + except (TypeError, ValueError): + repaired["refresh_interval"] = 60 + return repaired + + +def save_settings(settings: Settings) -> None: + """Сохранить настройки в config.toml.""" + path = _config_file() + path.parent.mkdir(parents=True, exist_ok=True) + _write_config_file(path, settings.model_dump(exclude_defaults=True)) + + +def _read_config_file() -> dict[str, Any]: + path = _config_file() + if not path.exists(): + return {} + with path.open("rb") as fh: + return dict(tomllib.load(fh)) + + +def _write_config_file(path: Path, data: dict[str, Any]) -> None: + try: + import tomli_w + except ImportError: # pragma: no cover + # tomli_w не установлен — пишем JSON (совместимый фолбэк) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + return + path.write_text(tomli_w.dumps(data)) + + +def load_credentials() -> Credentials: + """Загрузить секреты: env (SELCTEL_*) > файл credentials.json. + + Env-алиасы: SELCTEL_TOKEN (static_token), SELCTEL_SERVICE_USER, + SELCTEL_PASSWORD, SELCTEL_PROJECT_ID, SELCTEL_DOMAIN_ID. + """ + path = _credentials_file() + if path.exists(): + with path.open(encoding="utf-8") as fh: + data: dict[str, Any] = json.load(fh) + else: + data = {} + + env_overrides = { + "static_token": os.environ.get("SELCTEL_TOKEN"), + "service_user": os.environ.get("SELCTEL_SERVICE_USER"), + "password": os.environ.get("SELCTEL_PASSWORD"), + "project_id": os.environ.get("SELCTEL_PROJECT_ID"), + "domain_id": os.environ.get("SELCTEL_DOMAIN_ID"), + } + for name, value in env_overrides.items(): + if value is not None: + data[name] = value + return Credentials.model_validate(data) + + +def save_credentials(credentials: Credentials) -> None: + """Сохранить секреты в файл credentials.json с правами 0600.""" + path = _credentials_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(credentials.model_dump_json(indent=2)) + _chmod_0600(path) + + +def _chmod_0600(path: Path) -> None: + if os.name == "posix": # pragma: no cover + path.chmod(0o600) diff --git a/src/selectel_ml_tui/models.py b/src/selectel_ml_tui/models.py new file mode 100644 index 0000000..ae8fb8f --- /dev/null +++ b/src/selectel_ml_tui/models.py @@ -0,0 +1,197 @@ +"""Pydantic-модели данных Selectel ML. + +Модели ответов Billing Statistics API и Gateway API ИИ-роутера, а также +агрегированные структуры для TUI (потребление, метрики, история). + +Все модели устойчивы к отсутствующим полям (значения по умолчанию) и +валидируют неотрицательные числа. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def parse_iso_dt(value: str) -> datetime: + """Распарсить ISO-дату, допускающую 'Z' и нативные (без tz) значения.""" + text = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(text) + except ValueError as exc: + raise ValueError(f"Некорректная дата: {value!r}") from exc + + +def _validate_dt(value: Any) -> Any: + if isinstance(value, str): + return parse_iso_dt(value) + return value + + +def format_rub(amount: Decimal | int | float | str) -> str: + """Сумма в рублях: 854.83 -> '854,83 ₽', 1234.5 -> '1 234,50 ₽'.""" + value = Decimal(str(amount)) + rub, kop = f"{value:.2f}".split(".") + rub = f"{int(rub):,}".replace(",", " ") + return f"{rub},{kop} ₽" + + +def format_tokens(value: int) -> str: + """Число токенов с разделителями: 1234567 -> '1 234 567'.""" + return f"{int(value):,}".replace(",", " ") + + +def format_percent(value: float) -> str: + """Процент: 99.912 -> '99,9%'.""" + return f"{float(value):.1f}".replace(".", ",") + "%" + + +class TokenUsage(BaseModel): + """Метрики токенов (все неотрицательные).""" + + input_tokens: int = Field(default=0, ge=0) + output_tokens: int = Field(default=0, ge=0) + cache_read_tokens: int = Field(default=0, ge=0) + cache_write_tokens: int = Field(default=0, ge=0) + + +class Consumption(TokenUsage): + """Потребление за период: стоимость + токены.""" + + period: datetime + amount_rub: Decimal = Field(default=Decimal("0"), ge=0) + requests: int = Field(default=0, ge=0) + tokens_total: int = Field(default=0, ge=0) + + _validate_period = field_validator("period", mode="before")(_validate_dt) + + +class Metrics(TokenUsage): + """Общие метрики роутера за 24 часа.""" + + availability_pct: float = Field(default=100.0, ge=0, le=100) + avg_ttft_ms: float = Field(default=0.0, ge=0) + avg_latency_ms: float = Field(default=0.0, ge=0) + requests: int = Field(default=0, ge=0) + + +class ModelMetrics(Metrics): + """Метрики по конкретной модели.""" + + model_id: str = "" + model_name: str = "" + + +class ApiKeyUsage(BaseModel): + """Лимиты и расход по одному API-ключу роутера.""" + + name: str = "" + prefix: str = "" + is_active: bool = True + spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0) + spend_rub: Decimal = Field(default=Decimal("0"), ge=0) + remaining_rub: Decimal = Field(default=Decimal("0"), ge=0) + + +class BudgetInfo(BaseModel): + """Бюджет роутера: лимит, расход, остаток.""" + + spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0) + spend_rub: Decimal = Field(default=Decimal("0"), ge=0) + remaining_rub: Decimal = Field(default=Decimal("0"), ge=0) + window: str = "" + + +class HistoryPoint(BaseModel): + """Точка истории для графика: время, стоимость, токены.""" + + timestamp: datetime + amount_rub: Decimal = Field(default=Decimal("0"), ge=0) + tokens: TokenUsage = Field(default_factory=TokenUsage) + + _validate_timestamp = field_validator("timestamp", mode="before")(_validate_dt) + + +class MetricInfo(BaseModel): + """Информация о метрике из billing statistics API.""" + + id: str = "" + name: str = "" + unit: str = "" + quantity: Decimal | None = None + locale: str | None = None + region: str | None = None + + +class ProjectRef(BaseModel): + """Ссылка на проект (ML).""" + + id: str = "" + name: str = "" + + +class ObjectRef(BaseModel): + """Ссылка на объект (ключ/роутер).""" + + id: str = "" + name: str = "" + parent_id: str | None = None + parent_name: str | None = None + type: str | None = None + type_name: str | None = None + + +class ConsumptionItem(BaseModel): + """Элемент потребления из /v1/cloud_billing/statistic/consumption. + + ``project`` и ``object`` могут быть строкой (id), объектом или null. + """ + + model_config = ConfigDict(populate_by_name=True) + + account_id: str = "" + period: str = "" + provider_key: str = "" + metric: MetricInfo | None = None + value: Decimal = Decimal("0") + project: ProjectRef | str | None = Field(default=None, alias="project") + object: ObjectRef | str | None = Field(default=None, alias="object") + balance: Decimal | None = None + provision_start: str | None = None + provision_end: str | None = None + + @property + def project_id(self) -> str | None: + """ID проекта (из строки или объекта).""" + if isinstance(self.project, ProjectRef): + return self.project.id + return self.project + + @property + def object_id(self) -> str | None: + """ID объекта (из строки или объекта).""" + if isinstance(self.object, ObjectRef): + return self.object.id + return self.object + + +class RouterOverview(BaseModel): + """Потребление ИИ-роутера (GET /aig/gateway/v1/gateways/{id}/overview). + + ``raw`` хранит необработанные данные до полного описания схемы. + """ + + raw: dict[str, Any] = Field( + default_factory=dict, description="Необработанные данные ответа" + ) + + +class RouterMetrics(BaseModel): + """Метрики ИИ-роутера за 24 часа (GET /aig/gateway/v1/gateways/{id}/metrics).""" + + raw: dict[str, Any] = Field( + default_factory=dict, description="Необработанные данные ответа" + ) diff --git a/src/selectel_ml_tui/platform.py b/src/selectel_ml_tui/platform.py new file mode 100644 index 0000000..d556a3f --- /dev/null +++ b/src/selectel_ml_tui/platform.py @@ -0,0 +1,83 @@ +"""Платформенно-зависимые проверки и диагностика окружения. + +Изолирует код, зависящий от ОС и терминала (Python, TERM, LANG, кодировка), +чтобы его можно было тестировать на любой платформе. +""" + +from __future__ import annotations + +import os +import platform as _platform +import sys +from dataclasses import dataclass + +MIN_PYTHON = (3, 10) + +# TERM-значения, которые Textual считает пригодными для TUI. +_SUPPORTED_TERM_MARKERS = ("xterm", "linux", "tmux", "screen") + + +@dataclass(frozen=True) +class PlatformInfo: + """Снимок окружения, на котором запущено приложение.""" + + system: str + python_version: str + term: str + lang: str + encoding: str + utf8: bool + + +@dataclass(frozen=True) +class CheckResult: + """Результат одной проверки окружения.""" + + ok: bool + name: str + detail: str + + +def get_platform_info() -> PlatformInfo: + """Собрать информацию об ОС и терминале.""" + encoding = sys.stdout.encoding or sys.getdefaultencoding() or "" + return PlatformInfo( + system=_platform.system(), + python_version=_platform.python_version(), + term=os.environ.get("TERM", ""), + lang=os.environ.get("LANG", ""), + encoding=encoding, + utf8=encoding.lower().replace("-", "").replace("_", "") == "utf8", + ) + + +def check_environment() -> list[CheckResult]: + """Проверить окружение: Python, TERM, LANG, кодировку UTF-8.""" + info = get_platform_info() + major, minor = (int(x) for x in _platform.python_version_tuple()[:2]) + checks: list[CheckResult] = [ + CheckResult( + ok=(major, minor) >= MIN_PYTHON, + name="python", + detail=( + f"{info.python_version} (требуется >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})" + ), + ), + CheckResult( + ok=bool(info.term) + and any(marker in info.term for marker in _SUPPORTED_TERM_MARKERS), + name="TERM", + detail=info.term or "не задан (рекомендуется xterm-256color)", + ), + CheckResult( + ok=bool(info.lang), + name="LANG", + detail=info.lang or "не задан (рекомендуется *.UTF-8)", + ), + CheckResult( + ok=info.utf8, + name="UTF-8", + detail=f"{info.encoding} (stdout)", + ), + ] + return checks diff --git a/src/selectel_ml_tui/services.py b/src/selectel_ml_tui/services.py new file mode 100644 index 0000000..97f6386 --- /dev/null +++ b/src/selectel_ml_tui/services.py @@ -0,0 +1,365 @@ +"""Сервисный слой: агрегация данных для TUI. + +Объединяет Billing API (стоимость за месяц) и Gateway API (метрики, токены, +модели). При недоступности API подставляет последние данные из SQLite-кэша +(офлайн-режим). +""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, Field + +from selectel_ml_tui.api.billing import BillingClient +from selectel_ml_tui.api.client import SelectelApiError +from selectel_ml_tui.api.gateway import GatewayClient +from selectel_ml_tui.auth import IamClient, IamTokenManager +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import ( + ApiKeyUsage, + BudgetInfo, + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.storage import Storage + +_NANO = Decimal("1000000000") + + +def _nano_to_rub(value: Any) -> Decimal: + """Нанорубли (строка/число) -> рубли.""" + if value is None: + return Decimal("0") + try: + return Decimal(str(value)) / _NANO + except (ValueError, ArithmeticError, TypeError): + return Decimal("0") + + +def _pick(data: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +def _dict(value: Any) -> dict[str, Any]: + """Привести произвольное значение к dict (иначе пустой dict).""" + return value if isinstance(value, dict) else {} + + +def parse_tokens(data: dict[str, Any]) -> TokenUsage: + """Извлечь токены из объекта ответа (устойчиво к разным ключам). + + Поддерживает как вложенную структуру Gateway (``tokens.input`` и т.п.), + так и плоские ключи (``input_tokens`` и т.п.). + """ + nested = _dict(data.get("tokens")) + return TokenUsage( + input_tokens=int( + _pick(data, ("input_tokens", "input", "inputTokens", "tokens_in")) + or _pick(nested, ("input", "input_tokens")) + or 0 + ), + output_tokens=int( + _pick(data, ("output_tokens", "output", "outputTokens", "tokens_out")) + or _pick(nested, ("output", "output_tokens")) + or 0 + ), + cache_read_tokens=int( + _pick(data, ("cache_read_tokens", "cacheReadTokens", "cache_read")) + or _pick(nested, ("cached_input", "cache_read_tokens", "cache_read")) + or 0 + ), + cache_write_tokens=int( + _pick(data, ("cache_write_tokens", "cacheWriteTokens", "cache_write")) + or _pick(nested, ("cache_write", "cache_write_tokens")) + or 0 + ), + ) + + +def parse_overview(raw: dict[str, Any], period: dt.datetime) -> Consumption: + """Разобрать /gateways/{id}/overview в Consumption. + + Сумма из ``totals.cost_rub_nano`` (нанорубли), токены из ``totals.tokens``, + общее число токенов из ``totals.tokens_total.total``. + """ + totals = _dict(raw.get("totals")) + tokens_total = _dict(totals.get("tokens_total")) + tokens = parse_tokens(totals) + return Consumption( + period=period, + amount_rub=_nano_to_rub(totals.get("cost_rub_nano")), + requests=int(totals.get("requests") or 0), + tokens_total=int(tokens_total.get("total") or 0), + **tokens.model_dump(), + ) + + +def parse_metrics(raw: dict[str, Any]) -> Metrics | None: + """Разобрать /gateways/{id}/metrics в Metrics. + + Доступность из ``global.success_rate_pct``, TTFT/latency из секунд + ``global.avg_ttft_seconds``/``avg_latency_seconds`` (в мс), токены из + ``global.tokens``. + """ + if not raw: + return None + global_ = _dict(raw.get("global")) or raw + tokens = parse_tokens(global_) + return Metrics( + availability_pct=float(global_.get("success_rate_pct") or 100.0), + avg_ttft_ms=float(global_.get("avg_ttft_seconds") or 0.0) * 1000, + avg_latency_ms=float(global_.get("avg_latency_seconds") or 0.0) * 1000, + requests=int(global_.get("requests") or 0), + input_tokens=tokens.input_tokens, + output_tokens=tokens.output_tokens, + cache_read_tokens=tokens.cache_read_tokens, + cache_write_tokens=tokens.cache_write_tokens, + ) + + +def parse_models(raw_metrics: dict[str, Any]) -> list[ModelMetrics]: + """Построить метрики по моделям из ``by_model`` в /metrics. + + По модели API отдаёт: ``model``, ``requests``, ``success_rate_pct``, + ``avg_ttft_seconds`` (токенов по модели нет). + """ + by_model = raw_metrics.get("by_model", []) + if not isinstance(by_model, list): + by_model = [] + models: list[ModelMetrics] = [] + for item in by_model: + model_id = str(item.get("model") or "") if isinstance(item, dict) else "" + models.append( + ModelMetrics( + model_id=model_id, + model_name=model_id, + availability_pct=float(item.get("success_rate_pct") or 100.0) + if isinstance(item, dict) + else 100.0, + avg_ttft_ms=( + float(item.get("avg_ttft_seconds") or 0.0) * 1000 + if isinstance(item, dict) + else 0.0 + ), + avg_latency_ms=( + float(item.get("avg_latency_seconds") or 0.0) * 1000 + if isinstance(item, dict) + else 0.0 + ), + requests=( + int(item.get("requests") or 0) if isinstance(item, dict) else 0 + ), + ) + ) + return models + + +def parse_keys(raw_keys: list[dict[str, Any]]) -> list[ApiKeyUsage]: + """Разобрать /keys в список лимитов по API-ключам.""" + keys: list[ApiKeyUsage] = [] + for item in raw_keys: + budget = _dict(item.get("budget")) + limit = _nano_to_rub(budget.get("spend_limit_rub_nano")) + usage = _dict(budget.get("current_usage")) or _dict(item.get("usage")) + spend = _nano_to_rub(usage.get("spend_rub_nano")) + keys.append( + ApiKeyUsage( + name=str(item.get("name") or ""), + prefix=str(item.get("prefix") or ""), + is_active=bool(item.get("is_active", True)), + spend_limit_rub=limit, + spend_rub=spend, + remaining_rub=max(limit - spend, Decimal("0")), + ) + ) + return keys + + +def parse_budget(raw: dict[str, Any]) -> BudgetInfo | None: + """Бюджет роутера из ``budget`` в /overview.""" + budget = _dict(raw.get("budget")) + if not budget: + return None + limit = _nano_to_rub(budget.get("spend_limit_rub_nano")) + spend = _nano_to_rub(budget.get("spend_rub_nano")) + return BudgetInfo( + spend_limit_rub=limit, + spend_rub=spend, + remaining_rub=max(limit - spend, Decimal("0")), + window=str(budget.get("window") or ""), + ) + + +class DashboardData(BaseModel): + """Данные для отображения на главном экране.""" + + month_consumption: Consumption | None = None + today_amount_rub: Decimal | None = None + metrics: Metrics | None = None + models: list[ModelMetrics] = Field(default_factory=list) + keys: list[ApiKeyUsage] = Field(default_factory=list) + budget: BudgetInfo | None = None + offline: bool = False + updated_at: dt.datetime | None = None + error: str | None = None + source: str = "live" + + @property + def has_data(self) -> bool: + return ( + self.month_consumption is not None + or self.metrics is not None + or bool(self.models) + ) + + +class DataService: + """Собирает данные из API и сохраняет снимки в кэш.""" + + def __init__( + self, + settings: Settings, + credentials: Credentials, + storage: Storage | None = None, + transport: Any = None, + ) -> None: + self._settings = settings + self._credentials = credentials + self._storage = storage + self._transport = transport + + async def refresh(self) -> DashboardData: + """Получить актуальные данные (online) или последние из кэша (offline).""" + now = dt.datetime.now(dt.timezone.utc) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + data = DashboardData(updated_at=now) + errors: list[str] = [] + + await self._load_billing(data, month_start, now, errors) + await self._load_gateway(data, errors) + + if data.month_consumption is None and self._storage is not None: + cached = self._storage.get_latest_consumption() + if cached is not None: + data.month_consumption = cached + data.offline = True + data.source = "cache" + + if errors: + data.error = "; ".join(errors) + + if data.month_consumption is not None and self._storage is not None: + history_amount = data.today_amount_rub + if history_amount is None: + history_amount = data.month_consumption.amount_rub + self._storage.save_snapshot( + consumption=data.month_consumption, + metrics=data.metrics, + models=data.models, + history_point=HistoryPoint( + timestamp=now.replace(hour=0, minute=0, second=0, microsecond=0), + amount_rub=history_amount, + tokens=TokenUsage( + input_tokens=data.metrics.input_tokens if data.metrics else 0, + output_tokens=( + data.metrics.output_tokens if data.metrics else 0 + ), + cache_read_tokens=( + data.metrics.cache_read_tokens if data.metrics else 0 + ), + cache_write_tokens=( + data.metrics.cache_write_tokens if data.metrics else 0 + ), + ), + ), + ) + return data + + async def _load_billing( + self, + data: DashboardData, + month_start: dt.datetime, + now: dt.datetime, + errors: list[str], + ) -> None: + billing = BillingClient( + self._settings.api_base_url, + self._credentials.static_token, + transport=self._transport, + ) + try: + # end — текущий момент с временем, чтобы включить сегодняшний день + # (end = дата без времени отсекает текущий день из суммы). + # API принимает только naive datetime (без смещения часового пояса). + end_full = now.replace(tzinfo=None).isoformat(timespec="milliseconds") + total_kop = await billing.get_summary( + month_start.date().isoformat(), end_full + ) + data.month_consumption = Consumption(period=now, amount_rub=total_kop / 100) + # Дневная стоимость сегодняшнего дня — для точки истории. + try: + today_items = await billing.get_consumption( + now.date().isoformat(), end_full, period_group_type="day" + ) + today_total = sum( + (item.value for item in today_items), start=Decimal("0") + ) + data.today_amount_rub = today_total / 100 + except SelectelApiError: + data.today_amount_rub = None + except SelectelApiError as exc: + errors.append(f"Billing: {exc}") + finally: + await billing.aclose() + + async def _load_gateway(self, data: DashboardData, errors: list[str]) -> None: + """Gateway API требует IAM-доступ; недоступность не роняет дашборд.""" + if not self._credentials.service_user or not self._credentials.password: + errors.append( + "Gateway: не настроен сервисный пользователь (токены/модели недоступны)" + ) + return + try: + iam = IamClient( + self._settings, self._credentials, transport=self._transport + ) + provider = IamTokenManager(iam) + gateway = GatewayClient( + self._settings.api_base_url, + provider, + self._credentials.project_id, + self._credentials.domain_id, + transport=self._transport, + ) + try: + gateways = await gateway.list_gateways() + router_id = self._settings.router_id or ( + str(gateways[0]["id"]) if gateways else "" + ) + if router_id: + overview = await gateway.get_overview(router_id) + metrics = await gateway.get_metrics(router_id) + data.metrics = parse_metrics(metrics.raw) + data.models = parse_models(metrics.raw) + data.keys = parse_keys(await gateway.list_keys()) + data.budget = parse_budget(overview.raw) + # Сумма за месяц из Gateway Overview (совпадает с вкладкой + # «Обзор»); биллинг используется как фолбэк. + data.month_consumption = parse_overview( + overview.raw, dt.datetime.now(dt.timezone.utc) + ) + else: + errors.append("Gateway: нет ИИ-роутеров") + finally: + await gateway.aclose() + except Exception as exc: # noqa: BLE001 - на уровне сервиса ловим всё + errors.append(f"Gateway: {exc}") diff --git a/src/selectel_ml_tui/storage.py b/src/selectel_ml_tui/storage.py new file mode 100644 index 0000000..75c02bb --- /dev/null +++ b/src/selectel_ml_tui/storage.py @@ -0,0 +1,361 @@ +"""Локальный кэш и история данных (SQLite). + +База данных в каталоге данных платформы (Linux: ~/.local/share, +macOS: ~/Library/Application Support). Хранит снимки потребления и метрик +при каждом успешном обновлении, а также историю по периодам для графиков. +При недоступности API TUI может показать последние данные из кэша. +""" + +from __future__ import annotations + +import csv +import datetime as dt +import json +import sqlite3 +from decimal import Decimal +from pathlib import Path +from typing import Any, cast + +from selectel_ml_tui.config import data_dir +from selectel_ml_tui.models import ( + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, + parse_iso_dt, +) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS consumption_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + period TEXT NOT NULL, + amount_rub TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS metrics_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + availability_pct REAL NOT NULL DEFAULT 100, + avg_ttft_ms REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS model_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + model_id TEXT NOT NULL, + model_name TEXT NOT NULL DEFAULT '', + availability_pct REAL NOT NULL DEFAULT 100, + avg_ttft_ms REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS history ( + timestamp TEXT PRIMARY KEY, + amount_rub TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +""" + + +def default_db_path() -> Path: + """Путь к базе данных по умолчанию (каталог данных платформы).""" + return data_dir() / "cache.db" + + +def _utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def _history_columns(point: HistoryPoint) -> tuple[Any, ...]: + return ( + point.timestamp.isoformat(), + str(point.amount_rub), + point.tokens.input_tokens, + point.tokens.output_tokens, + point.tokens.cache_read_tokens, + point.tokens.cache_write_tokens, + ) + + +def _history_from_row(row: sqlite3.Row) -> HistoryPoint: + return HistoryPoint( + timestamp=parse_iso_dt(cast(str, row["timestamp"])), + amount_rub=Decimal(str(row["amount_rub"])), + tokens=TokenUsage( + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ), + ) + + +class Storage: + """SQLite-кэш: снимки потребления/метрик и история для графиков.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path if path is not None else default_db_path() + self._path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._path) + self._conn.row_factory = sqlite3.Row + self._init_schema() + + @property + def path(self) -> Path: + return self._path + + def _init_schema(self) -> None: + self._conn.executescript(SCHEMA) + self._conn.commit() + + def save_consumption(self, item: Consumption) -> None: + """Сохранить снимок потребления.""" + self._conn.execute( + """ + INSERT INTO consumption_snapshots + (created_at, period, amount_rub, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + _utc_now(), + item.period.isoformat(), + str(item.amount_rub), + item.input_tokens, + item.output_tokens, + item.cache_read_tokens, + item.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_metrics(self, metrics: Metrics) -> None: + """Сохранить снимок общих метрик.""" + self._conn.execute( + """ + INSERT INTO metrics_snapshots + (created_at, availability_pct, avg_ttft_ms, input_tokens, + output_tokens, cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + _utc_now(), + metrics.availability_pct, + metrics.avg_ttft_ms, + metrics.input_tokens, + metrics.output_tokens, + metrics.cache_read_tokens, + metrics.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_model_metrics(self, models: list[ModelMetrics]) -> None: + """Сохранить снимок метрик по моделям (один пакет = один created_at).""" + batch = _utc_now() + for model in models: + self._conn.execute( + """ + INSERT INTO model_metrics + (created_at, model_id, model_name, availability_pct, + avg_ttft_ms, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + batch, + model.model_id, + model.model_name, + model.availability_pct, + model.avg_ttft_ms, + model.input_tokens, + model.output_tokens, + model.cache_read_tokens, + model.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_history(self, point: HistoryPoint) -> None: + """Сохранить/обновить точку истории (upsert по timestamp).""" + self._conn.execute( + """ + INSERT OR REPLACE INTO history + (timestamp, amount_rub, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?) + """, + _history_columns(point), + ) + self._conn.commit() + + def save_snapshot( + self, + *, + consumption: Consumption | None = None, + metrics: Metrics | None = None, + models: list[ModelMetrics] | None = None, + history_point: HistoryPoint | None = None, + ) -> None: + """Сохранить полный снимок данных после успешного обновления.""" + if consumption is not None: + self.save_consumption(consumption) + if metrics is not None: + self.save_metrics(metrics) + if models: + self.save_model_metrics(models) + if history_point is not None: + self.save_history(history_point) + + def get_latest_consumption(self) -> Consumption | None: + """Последний снимок потребления (для офлайн-режима).""" + row = self._conn.execute( + "SELECT * FROM consumption_snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + return None + return Consumption( + period=parse_iso_dt(cast(str, row["period"])), + amount_rub=Decimal(str(row["amount_rub"])), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + + def get_latest_metrics(self) -> Metrics | None: + """Последний снимок общих метрик.""" + row = self._conn.execute( + "SELECT * FROM metrics_snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + return None + return Metrics( + availability_pct=cast(float, row["availability_pct"]), + avg_ttft_ms=cast(float, row["avg_ttft_ms"]), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + + def get_latest_model_metrics(self) -> list[ModelMetrics]: + """Снимок метрик по моделям за последнее обновление.""" + rows = self._conn.execute( + """ + SELECT * FROM model_metrics + WHERE created_at = (SELECT MAX(created_at) FROM model_metrics) + ORDER BY model_id + """ + ).fetchall() + models: list[ModelMetrics] = [] + for row in rows: + models.append( + ModelMetrics( + model_id=cast(str, row["model_id"]), + model_name=cast(str, row["model_name"]), + availability_pct=cast(float, row["availability_pct"]), + avg_ttft_ms=cast(float, row["avg_ttft_ms"]), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + ) + return models + + def get_history(self, days: int = 7) -> list[HistoryPoint]: + """Серия точек истории за последние ``days`` дней для графика.""" + cutoff = ( + dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days) + ).isoformat() + rows = self._conn.execute( + "SELECT * FROM history WHERE timestamp >= ? ORDER BY timestamp ASC", + (cutoff,), + ).fetchall() + return [_history_from_row(row) for row in rows] + + def cleanup(self, max_age_days: int = 180) -> int: + """Удалить записи старше ``max_age_days`` дней. Возвращает число удалённых.""" + cutoff = ( + dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=max_age_days) + ).isoformat() + deleted = 0 + for table, column in ( + ("consumption_snapshots", "created_at"), + ("metrics_snapshots", "created_at"), + ("model_metrics", "created_at"), + ("history", "timestamp"), + ): + cursor = self._conn.execute( + f"DELETE FROM {table} WHERE {column} < ?", (cutoff,) + ) + deleted += cursor.rowcount + self._conn.commit() + return deleted + + def export_json(self, path: Path) -> None: + """Экспортировать всю историю в JSON.""" + points = self.get_history(days=3650) + data = [ + { + "timestamp": point.timestamp.isoformat(), + "amount_rub": str(point.amount_rub), + "input_tokens": point.tokens.input_tokens, + "output_tokens": point.tokens.output_tokens, + "cache_read_tokens": point.tokens.cache_read_tokens, + "cache_write_tokens": point.tokens.cache_write_tokens, + } + for point in points + ] + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + def export_csv(self, path: Path) -> None: + """Экспортировать всю историю в CSV.""" + points = self.get_history(days=3650) + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow( + [ + "timestamp", + "amount_rub", + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + ] + ) + for point in points: + writer.writerow(_history_columns(point)) + + def clear(self) -> int: + """Полностью очистить кэш. Возвращает число удалённых записей.""" + deleted = 0 + for table in ( + "consumption_snapshots", + "metrics_snapshots", + "model_metrics", + "history", + ): + cursor = self._conn.execute(f"DELETE FROM {table}") + deleted += cursor.rowcount + self._conn.commit() + return deleted + + def close(self) -> None: + self._conn.close() diff --git a/src/selectel_ml_tui/ui/__init__.py b/src/selectel_ml_tui/ui/__init__.py new file mode 100644 index 0000000..ea8cd86 --- /dev/null +++ b/src/selectel_ml_tui/ui/__init__.py @@ -0,0 +1,5 @@ +"""TUI-интерфейс selectel-ml-tui.""" + +from selectel_ml_tui.ui.app import SelectelMLTUI, run_app + +__all__ = ["SelectelMLTUI", "run_app"] diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py new file mode 100644 index 0000000..2d6880d --- /dev/null +++ b/src/selectel_ml_tui/ui/app.py @@ -0,0 +1,589 @@ +"""TUI-приложение selectel-ml-tui на Textual. + +Экраны: Dashboard (главный), History (история), Settings (настройки). +Автообновление по таймеру, асинхронная загрузка через workers, офлайн-режим +с показом последних данных из SQLite-кэша. +""" + +from __future__ import annotations + +from typing import Any + +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Input, + Static, +) + +from selectel_ml_tui.config import ( + config_dir, + data_dir, + load_credentials, + load_settings, + save_settings, +) +from selectel_ml_tui.models import ( + ApiKeyUsage, + ModelMetrics, + format_percent, + format_rub, + format_tokens, +) +from selectel_ml_tui.services import DashboardData, DataService +from selectel_ml_tui.storage import Storage + +PERIOD_OPTIONS = (7, 30, 90) + + +def _mask_token(value: str) -> str: + if not value: + return "(не задан)" + return f"{value[:4]}…({len(value)})" + + +class ConsumptionBars(Static): + """Unicode-бар-график потребления (столбцы).""" + + data: reactive[list[float]] = reactive([]) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.height = 7 + + def render(self) -> Any: + values = self.data + if not values: + return "Нет данных для графика" + maximum = max(values) + if maximum <= 0: + return "Нет данных для графика" + lines: list[str] = [] + for level in range(self.height, 0, -1): + row = "" + for value in values: + bar_height = max(1, int(value / maximum * self.height)) + row += "█" if bar_height >= level else " " + lines.append(row) + return "\n".join(lines) + f"\nmax: {maximum:.0f} ₽" + + +class DashboardScreen(Screen[Any]): + """Главный экран: потребление за месяц, метрики, модели.""" + + BINDINGS = [ + Binding("r", "refresh", "Обновить", show=True), + Binding("h", "history", "История", show=True), + Binding("k", "keys", "Ключи", show=True), + Binding("s", "settings", "Настройки", show=True), + ] + + data = reactive(DashboardData()) + loading = reactive(False) + + def __init__( + self, + service: DataService, + refresh_interval: int, + storage: Storage, + settings: Any, + credentials: Any, + ) -> None: + super().__init__() + self._service = service + self._refresh_interval = refresh_interval + self._storage = storage + self._settings = settings + self._credentials = credentials + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Vertical(id="dashboard"): + with Horizontal(id="panels"): + yield Static("", id="month-panel", classes="panel") + yield Static("", id="metrics-panel", classes="panel") + yield Static("По моделям", id="models-title") + yield DataTable(id="models-table") + yield Footer() + + def on_mount(self) -> None: + self.set_interval(self._refresh_interval, self._refresh) + self._refresh() + + @work(exclusive=True, group="refresh") + async def _refresh(self) -> None: + self.loading = True + try: + self.data = await self._service.refresh() + except Exception as exc: # noqa: BLE001 - показываем toast при ошибке + self.notify(f"Ошибка обновления: {exc}", severity="error") + finally: + self.loading = False + + def watch_data(self, data: DashboardData) -> None: + status = "offline" if data.offline else "online" + amount = ( + format_rub(data.month_consumption.amount_rub) + if data.month_consumption + else "—" + ) + self.app.sub_title = f"{status} · {amount}" + self._update_month_panel(data) + self._update_metrics_panel(data) + self._update_models_table(data) + + def _update_month_panel(self, data: DashboardData) -> None: + text = "[bold]Потребление за месяц[/]\n\n" + if data.month_consumption is None: + text += "Нет данных" + else: + c = data.month_consumption + text += f"Сумма: [b]{format_rub(c.amount_rub)}[/]\n" + text += f"Запросы: {format_tokens(c.requests)}\n" + text += f"Токены: {format_tokens(c.tokens_total)}\n" + if data.today_amount_rub is not None: + text += f"[dim]Сегодня: {format_rub(data.today_amount_rub)}[/]\n" + if data.metrics is not None: + m = data.metrics + text += f"Input: {format_tokens(m.input_tokens)}\n" + text += f"Output: {format_tokens(m.output_tokens)}\n" + text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n" + text += f"Cache write: {format_tokens(m.cache_write_tokens)}" + else: + text += "\n[dim](токены — только через IAM)[/]" + if data.budget is not None: + b = data.budget + text += ( + f"\n\n[bold]Бюджет[/]\n" + f"Лимит: {format_rub(b.spend_limit_rub)}\n" + f"Потрачено: {format_rub(b.spend_rub)}\n" + f"Остаток: {format_rub(b.remaining_rub)}" + ) + if data.offline: + text += "\n\n[dim]офлайн (данные из кэша)[/]" + if data.error: + text += f"\n\n[red]{data.error}[/]" + self.query_one("#month-panel", Static).update(text) + + def _update_metrics_panel(self, data: DashboardData) -> None: + text = "[bold]Метрики за 24 часа[/]\n\n" + if data.metrics is None: + text += "Нет данных" + else: + m = data.metrics + text += f"Доступность: {format_percent(m.availability_pct)}\n" + text += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n" + text += f"Средняя задержка: {m.avg_latency_ms:.1f} мс\n" + text += f"Запросы: {format_tokens(m.requests)}\n" + text += f"Input: {format_tokens(m.input_tokens)}\n" + text += f"Output: {format_tokens(m.output_tokens)}\n" + text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n" + text += f"Cache write: {format_tokens(m.cache_write_tokens)}" + self.query_one("#metrics-panel", Static).update(text) + + def _update_models_table(self, data: DashboardData) -> None: + table = self.query_one("#models-table", DataTable) + if not table.columns: + table.add_columns( + "Модель", + "Запросы", + "Доступность", + "TTFT, мс", + "Задержка, мс", + ) + table.cursor_type = "row" + table.clear() + for model in data.models: + table.add_row( + model.model_name, + format_tokens(model.requests), + format_percent(model.availability_pct), + f"{model.avg_ttft_ms:.1f}", + f"{model.avg_latency_ms:.1f}", + ) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + event.stop() + if not self.data.models: + return + row = event.cursor_row + if row is None or row >= len(self.data.models): + return + self.app.push_screen(ModelDetailScreen(self.data.models[row])) + + def action_refresh(self) -> None: + self._refresh() + + def action_history(self) -> None: + self.app.push_screen(HistoryScreen(self._storage)) + + def action_keys(self) -> None: + self.app.push_screen(KeysScreen(self.data.keys, self.data.budget)) + + def action_settings(self) -> None: + self.app.push_screen( + SettingsScreen(self._settings, self._credentials, self._storage) + ) + + def action_model_detail(self) -> None: + if not self.data.models: + return + table = self.query_one("#models-table", DataTable) + row = table.cursor_row + if row is None: + row = 0 + if row >= len(self.data.models): + return + self.app.push_screen(ModelDetailScreen(self.data.models[row])) + + +class KeysScreen(Screen[Any]): + """Лимиты и расход по API-ключам роутера.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, keys: list[ApiKeyUsage], budget: Any | None = None) -> None: + super().__init__() + self._keys = keys + self._budget = budget + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("API-ключи и лимиты", id="keys-title") + if self._budget is not None: + yield Static("", id="keys-budget") + yield DataTable(id="keys-table") + yield Footer() + + def on_mount(self) -> None: + if self._budget is not None: + b = self._budget + self.query_one("#keys-budget", Static).update( + f"[bold]Бюджет роутера[/]\n" + f"Лимит: {format_rub(b.spend_limit_rub)} " + f"Потрачено: {format_rub(b.spend_rub)} " + f"Остаток: {format_rub(b.remaining_rub)}" + ) + table = self.query_one("#keys-table", DataTable) + table.add_columns("Ключ", "Префикс", "Статус", "Лимит", "Потрачено", "Остаток") + for key in self._keys: + table.add_row( + key.name, + key.prefix, + "активен" if key.is_active else "неактивен", + format_rub(key.spend_limit_rub), + format_rub(key.spend_rub), + format_rub(key.remaining_rub), + ) + + def action_back(self) -> None: + self.app.pop_screen() + + +class HistoryScreen(Screen[Any]): + """История потребления: бар-график и таблица по дням.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + Binding("r", "refresh", "Обновить", show=True), + ] + + def __init__(self, storage: Storage) -> None: + super().__init__() + self._storage = storage + self._days = 30 + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("История потребления", id="history-title") + with Horizontal(id="period-buttons"): + for days in PERIOD_OPTIONS: + yield Button( + f"{days} дней", + id=f"period-{days}", + variant="primary" if days == self._days else "default", + ) + yield Static("Стоимость, руб. (по дням)", id="bars-title") + yield ConsumptionBars("", id="history-bars") + yield DataTable(id="history-table") + yield Footer() + + def on_mount(self) -> None: + self._reload() + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id and button_id.startswith("period-"): + self._days = int(button_id.split("-", 1)[1]) + for days in PERIOD_OPTIONS: + button = self.query_one(f"#period-{days}", Button) + button.variant = "primary" if days == self._days else "default" + self._reload() + + def action_refresh(self) -> None: + self._reload() + + def _reload(self) -> None: + points = self._storage.get_history(days=self._days) + self.query_one("#history-title", Static).update( + f"История потребления (последние {self._days} дней)" + ) + values = [float(point.amount_rub) for point in points] + self.query_one("#history-bars", ConsumptionBars).data = values + + table = self.query_one("#history-table", DataTable) + if not table.columns: + table.add_columns("Дата", "Сумма", "Input", "Output", "Cache") + table.clear() + for point in reversed(points): + table.add_row( + str(point.timestamp.date()), + format_rub(point.amount_rub), + format_tokens(point.tokens.input_tokens), + format_tokens(point.tokens.output_tokens), + format_tokens( + point.tokens.cache_read_tokens + point.tokens.cache_write_tokens + ), + ) + + def action_back(self) -> None: + self.app.pop_screen() + + +class ModelDetailScreen(Screen[Any]): + """Детализация метрик конкретной модели.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, model: ModelMetrics) -> None: + super().__init__() + self._model = model + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("", id="model-detail") + yield Footer() + + def on_mount(self) -> None: + m = self._model + body = f"[b]{m.model_name}[/b]\nID: {m.model_id}\n\n" + body += f"Доступность: {format_percent(m.availability_pct)}\n" + body += f"Запросы: {format_tokens(m.requests)}\n" + body += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n" + body += f"Средняя задержка: {m.avg_latency_ms:.1f} мс" + self.query_one("#model-detail", Static).update(body) + + def action_back(self) -> None: + self.app.pop_screen() + + +class SettingsScreen(Screen[Any]): + """Настройки: интервал обновления, очистка кэша, пути и токен.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, settings: Any, credentials: Any, storage: Storage) -> None: + super().__init__() + self._settings = settings + self._credentials = credentials + self._storage = storage + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("Настройки", id="settings-title") + with Vertical(id="settings-body"): + yield Static("Интервал обновления, сек:", id="interval-label") + yield Input( + value=str(self._settings.refresh_interval), + id="interval-input", + type="integer", + ) + with Horizontal(id="settings-actions"): + yield Button("Сохранить", id="save-settings", variant="primary") + yield Button("Очистить кэш", id="clear-cache", variant="error") + yield Static("", id="settings-info") + yield Footer() + + def on_mount(self) -> None: + info = ( + f"API base URL: {self._settings.api_base_url}\n" + f"Router id: {self._settings.router_id or '(авто)'}\n" + f"Сервисный пользователь: " + f"{self._credentials.service_user or '(не задан)'}\n" + f"Токен: {_mask_token(self._credentials.static_token)}\n" + f"Config dir: {config_dir()}\n" + f"Data dir: {data_dir()}" + ) + self.query_one("#settings-info", Static).update(info) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "save-settings": + self._save_interval() + elif button_id == "clear-cache": + self._clear_cache() + + def _save_interval(self) -> None: + raw = self.query_one("#interval-input", Input).value + try: + interval = int(raw) + except ValueError: + self.notify("Некорректное значение интервала", severity="error") + return + if interval < 10: + self.notify("Интервал должен быть не менее 10 секунд", severity="error") + return + if interval == self._settings.refresh_interval: + return + self._settings.refresh_interval = interval + save_settings(self._settings) + self.notify("Настройки сохранены") + + def _clear_cache(self) -> None: + deleted = self._storage.clear() + self.notify(f"Кэш очищен: удалено записей: {deleted}", severity="information") + + def action_back(self) -> None: + self._save_interval() + self.app.pop_screen() + + +class SelectelMLTUI(App[Any]): + """Главное приложение.""" + + TITLE = "Selectel ML Tokens" + BINDINGS = [ + Binding("q", "quit", "Выход", show=False), + ] + + async def action_quit(self) -> None: + self.exit() + + CSS = """ + #panels { + height: 10; + } + .panel { + width: 1fr; + height: 100%; + border: round $primary; + padding: 1 2; + margin: 0 1 0 0; + } + #models-title { + margin: 1 0 0 0; + text-style: bold; + } + #models-table { + height: 8; + } + #keys-title { + margin: 1 0 0 0; + text-style: bold; + } + #keys-budget { + margin: 1 0; + padding: 1 2; + border: round $primary; + } + #keys-table { + height: 60%; + margin: 1 0 0 0; + } + #history-bars { + height: 9; + margin: 1 0; + border: round $secondary; + padding: 0 1; + } + #history-table { + height: 40%; + margin: 1 0 0 0; + } + #period-buttons { + height: 3; + margin: 1 0 0 0; + } + #period-buttons Button { + width: 10; + margin: 0 1 0 0; + } + #bars-title { + margin: 1 0 0 0; + text-style: bold; + } + #settings-body { + margin: 1 2; + } + #interval-label { + margin: 1 0 0 0; + } + #interval-input { + width: 20; + } + #settings-actions { + margin: 1 0; + } + #settings-actions Button { + width: 16; + margin: 0 1 0 0; + } + #settings-info { + margin: 1 0 0 0; + } + #model-detail { + margin: 2 4; + padding: 1 2; + border: round $primary; + } + """ + + def __init__( + self, + settings: Any | None = None, + credentials: Any | None = None, + storage: Storage | None = None, + service: DataService | None = None, + ) -> None: + super().__init__() + self._settings = settings if settings is not None else load_settings() + self._credentials = ( + credentials if credentials is not None else load_credentials() + ) + self._storage = storage if storage is not None else Storage() + self._service = ( + service + if service is not None + else DataService(self._settings, self._credentials, self._storage) + ) + + def on_mount(self) -> None: + self.push_screen( + DashboardScreen( + self._service, + self._settings.refresh_interval, + self._storage, + self._settings, + self._credentials, + ) + ) + + +def run_app() -> None: + """Запустить TUI-приложение (entry point).""" + app = SelectelMLTUI() + app.run() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..59672c4 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,296 @@ +"""Unit-тесты API-клиентов с моками httpx.MockTransport.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +import httpx +import pytest + +from selectel_ml_tui.api.billing import ( + BillingApiError, + BillingClient, + normalize_dt, +) +from selectel_ml_tui.api.client import SelectelApiError +from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient + + +class NoopSleep: + """Пауза-заглушка для ретраев (не ждём в тестах).""" + + def __init__(self) -> None: + self.calls: list[float] = [] + + async def __call__(self, seconds: float) -> None: + self.calls.append(seconds) + + +def _transport(handler: Any) -> httpx.MockTransport: + return httpx.MockTransport(handler) + + +def _billing_client( + handler: Any, max_retries: int = 2, sleep: NoopSleep | None = None +) -> BillingClient: + return BillingClient( + api_base_url="https://api.selectel.ru", + static_token="static-token", + max_retries=max_retries, + transport=_transport(handler), + sleep=sleep or NoopSleep(), + ) + + +def _ok_consumption() -> dict[str, Any]: + return { + "status": "success", + "data": [ + { + "account_id": "239633", + "period": "2026-08-01", + "provider_key": "aig", + "metric": { + "id": "air_tokens_cost", + "name": "Стоимость токенов", + "unit": "nanorub", + "quantity": "85483000000", + }, + "value": 854.83, + "project": "7bf2d2a328494e25b13d05a1926ca75a", + } + ], + } + + +async def test_billing_normalize_dt() -> None: + assert normalize_dt("2026-08-01") == "2026-08-01T00:00:00.000" + assert normalize_dt("2026-08-01T10:30:00") == "2026-08-01T10:30:00" + assert normalize_dt("2026-08-01T00:00:00.000") == "2026-08-01T00:00:00.000" + + +async def test_billing_gets_normalized_dates() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler) + await client.get_consumption("2026-08-01", "2026-08-19") + assert seen["start"] == "2026-08-01T00:00:00.000" + assert seen["end"] == "2026-08-19T00:00:00.000" + await client.aclose() + + +async def test_billing_get_consumption() -> None: + client = _billing_client( + lambda request: httpx.Response(200, json=_ok_consumption()) + ) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert len(items) == 1 + assert items[0].value == Decimal("854.83") + assert items[0].metric is not None + assert items[0].metric.id == "air_tokens_cost" + assert items[0].project_id == "7bf2d2a328494e25b13d05a1926ca75a" + await client.aclose() + + +async def test_billing_get_summary_sums_values() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert "provider_keys=aig" in request.url.query.decode() + return httpx.Response( + 200, + json={ + "status": "success", + "data": [ + {"value": "100.25"}, + {"value": 42.5}, + {"value": 0}, + ], + }, + ) + + client = _billing_client(handler) + total = await client.get_summary("2026-08-01", "2026-08-19") + assert total == Decimal("142.75") + await client.aclose() + + +async def test_billing_5xx_retries_then_success() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + if attempts["count"] == 1: + return httpx.Response(500, text="internal error") + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler, sleep=sleep) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert attempts["count"] == 2 + assert len(items) == 1 + assert sleep.calls == [0.5] + await client.aclose() + + +async def test_billing_429_retries_then_raises() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response(429, text="rate limit") + + client = _billing_client(handler, max_retries=2, sleep=sleep) + with pytest.raises(SelectelApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code == 429 + assert attempts["count"] == 3 + assert sleep.calls == [0.5, 1.0] + await client.aclose() + + +async def test_billing_4xx_no_retry() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response(400, text="bad request") + + client = _billing_client(handler, sleep=sleep) + with pytest.raises(BillingApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code == 400 + assert "HTTP 400" in str(exc_info.value) + assert attempts["count"] == 1 + assert sleep.calls == [] + await client.aclose() + + +async def test_billing_network_error_retries() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + if attempts["count"] == 1: + raise httpx.ConnectError("connection refused") + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler, sleep=sleep) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert attempts["count"] == 2 + assert len(items) == 1 + await client.aclose() + + +async def test_billing_timeout_retries_then_raises() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + raise httpx.ReadTimeout("timed out") + + client = _billing_client(handler, max_retries=2, sleep=sleep) + with pytest.raises(SelectelApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code is None + assert "Ошибка сети" in str(exc_info.value) + assert attempts["count"] == 3 + assert sleep.calls == [0.5, 1.0] + await client.aclose() + + +async def test_billing_non_success_status_raises() -> None: + client = _billing_client( + lambda request: httpx.Response(200, json={"status": "error", "data": []}) + ) + with pytest.raises(BillingApiError): + await client.get_consumption("2026-08-01", "2026-08-19") + await client.aclose() + + +class FakeTokenProvider: + """Токен-провайдер для GatewayClient в тестах.""" + + def __init__(self, tokens: list[str] | None = None) -> None: + self._tokens = tokens or ["token-1"] + self._index = 0 + self.calls = 0 + + async def get_token(self) -> str: + self.calls += 1 + token = self._tokens[min(self._index, len(self._tokens) - 1)] + self._index += 1 + return token + + def invalidate(self) -> None: + pass + + +async def test_gateway_list_gateways() -> None: + provider = FakeTokenProvider() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Auth-Token"] == "token-1" + assert request.headers["X-Project-Id"] == "proj-1" + assert request.headers["X-Roles"] == "reader" + return httpx.Response(200, json={"data": [{"id": "gw-1", "name": "Router"}]}) + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + transport=_transport(handler), + ) + gateways = await client.list_gateways() + assert gateways == [{"id": "gw-1", "name": "Router"}] + assert provider.calls == 1 + await client.aclose() + + +async def test_gateway_401_refreshes_token() -> None: + provider = FakeTokenProvider(tokens=["token-old", "token-new"]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.headers["X-Auth-Token"] == "token-old": + return httpx.Response(401, text="unauthorized") + return httpx.Response(200, json={"data": [{"id": "gw-2"}]}) + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + transport=_transport(handler), + ) + gateways = await client.list_gateways() + assert gateways == [{"id": "gw-2"}] + assert provider.calls == 2 + await client.aclose() + + +async def test_gateway_401_without_refresh_raises() -> None: + provider = FakeTokenProvider(tokens=["token-only"]) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="unauthorized") + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + max_retries=0, + transport=_transport(handler), + ) + with pytest.raises(GatewayApiError) as exc_info: + await client.list_gateways() + assert exc_info.value.status_code == 401 + await client.aclose() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..13df81f --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,119 @@ +"""Unit-тесты IAM-авторизации и кэша токена.""" + +from __future__ import annotations + +import datetime as dt + +import httpx +import pytest + +from selectel_ml_tui.auth import ( + IamAuthError, + IamClient, + IamToken, + IamTokenManager, +) +from selectel_ml_tui.config import Credentials, Settings + + +class FakeIamClient: + """Заглушка IamClient для проверки логики кэширования.""" + + def __init__(self, token: IamToken) -> None: + self._token = token + self.calls = 0 + + async def get_token(self) -> IamToken: + self.calls += 1 + return self._token + + +def _far_future() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=23) + + +def _expired() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=1) + + +async def test_iam_get_token_success() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = request.content.decode() + assert '"name":"svc-user"' in body + assert '"password":"secret"' in body + assert '"id":"proj-1"' in body + return httpx.Response( + 201, + headers={"X-Subject-Token": "iam-token-abc"}, + json={ + "token": { + "expires_at": "2026-08-20T10:00:00.000000Z", + } + }, + ) + + client = IamClient( + Settings(), + Credentials( + service_user="svc-user", + password="secret", + domain_id="239633", + project_id="proj-1", + ), + transport=httpx.MockTransport(handler), + ) + token = await client.get_token() + assert token.value == "iam-token-abc" + assert token.expires_at == dt.datetime(2026, 8, 20, 10, 0, tzinfo=dt.timezone.utc) + + +async def test_iam_get_token_missing_credentials() -> None: + client = IamClient(Settings(), Credentials()) + with pytest.raises(IamAuthError, match="логин/пароль"): + await client.get_token() + + +async def test_iam_get_token_missing_subject_token() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, json={"token": {"expires_at": "2026-08-20T10:00:00Z"}} + ) + + client = IamClient( + Settings(), + Credentials( + service_user="svc", password="pwd", domain_id="239633", project_id="p" + ), + transport=httpx.MockTransport(handler), + ) + with pytest.raises(IamAuthError, match="X-Subject-Token"): + await client.get_token() + + +async def test_iam_token_manager_caches() -> None: + fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + assert await manager.get_token() == "t1" + assert await manager.get_token() == "t1" + assert fake.calls == 1 + assert manager.is_valid() + + +async def test_iam_token_manager_refreshes_after_expiry() -> None: + fake = FakeIamClient(IamToken(value="t-expired", expires_at=_expired())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + assert await manager.get_token() == "t-expired" + assert not manager.is_valid() + assert await manager.get_token() == "t-expired" + assert fake.calls == 2 + + +async def test_iam_token_manager_invalidate() -> None: + fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + await manager.get_token() + assert manager.is_valid() + manager.invalidate() + assert not manager.is_valid() + await manager.get_token() + assert fake.calls == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..37bc2f9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,120 @@ +"""Базовые тесты CLI.""" + +from __future__ import annotations + +import subprocess +import sys + +from click.testing import CliRunner + +from selectel_ml_tui import __version__ +from selectel_ml_tui.cli import main +from selectel_ml_tui.config import load_credentials, load_settings + + +def test_version() -> None: + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.output + + +def test_main_module_help() -> None: + result = subprocess.run( + [sys.executable, "-m", "selectel_ml_tui", "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "doctor" in result.stdout + assert "run" in result.stdout + + +def test_config_paths(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["config", "--path"]) + assert result.exit_code == 0 + assert str(tmp_path) in result.output + + +def test_config_show_masks_secrets(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["config", "--show"]) + assert result.exit_code == 0 + assert "Static token" in result.output + assert "секрет" not in result.output.lower() + + +def test_config_wizard_saves(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + input_data = "\n".join( + [ + "https://api.example.test", # API base URL + "30", # интервал обновления + "gw-test", # ID роутера + "ru-3", # регион + "svc-user", # сервисный пользователь + "239633", # домен (аккаунт) + "7bf2d2a328494e25b13d05a1926ca75a", # project id + "secret-pass", # пароль + "secret-token", # статический токен + ] + ) + runner = CliRunner() + result = runner.invoke(main, ["config"], input=input_data) + assert result.exit_code == 0, result.output + assert "Сохранено" in result.output + + settings = load_settings() + assert settings.router_id == "gw-test" + assert settings.refresh_interval == 30 + assert settings.api_base_url == "https://api.example.test" + assert settings.region == "ru-3" + + creds = load_credentials() + assert creds.service_user == "svc-user" + assert creds.domain_id == "239633" + assert creds.project_id == "7bf2d2a328494e25b13d05a1926ca75a" + assert creds.password == "secret-pass" + assert creds.static_token == "secret-token" + + +def test_run_requires_credentials(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["run"]) + assert result.exit_code == 1 + assert "не настроены" in result.output + + +def test_config_wizard_rejects_small_interval(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + input_data = "\n".join( + [ + "https://api.example.test", # API base URL + "5", # интервал < 10 — будет отклонён + "30", # повторный ввод, валидный + "gw-test", # ID роутера + "", # регион (оставить) + "", # сервисный пользователь (оставить) + "", # домен (оставить) + "", # project id (оставить) + "", # пароль (оставить) + "", # статический токен (оставить) + "", # хвостовая строка (CliRunner отбрасывает последний пустой элемент) + ] + ) + runner = CliRunner() + result = runner.invoke(main, ["config"], input=input_data) + assert result.exit_code == 0, result.output + assert "не может быть меньше 10" in result.output + settings = load_settings() + assert settings.refresh_interval == 30 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0143fda --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,89 @@ +"""Тесты модуля конфигурации.""" + +from __future__ import annotations + +from selectel_ml_tui.config import ( + Credentials, + Settings, + load_credentials, + load_settings, + save_credentials, + save_settings, +) + + +def test_settings_defaults() -> None: + settings = load_settings() + assert settings.api_base_url == "https://api.selectel.ru" + assert settings.refresh_interval >= 10 + + +def test_save_settings_roundtrip(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = Settings(router_id="gw-test", refresh_interval=30) + save_settings(settings) + loaded = load_settings() + assert loaded.router_id == "gw-test" + assert loaded.refresh_interval == 30 + + +def test_load_settings_repairs_small_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + cfg_file.write_text("refresh_interval = 5\n", encoding="utf-8") + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = load_settings() + assert settings.refresh_interval == 10 + + +def test_load_settings_repairs_garbage_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + cfg_file.write_text('refresh_interval = "abc"\n', encoding="utf-8") + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = load_settings() + assert settings.refresh_interval == 60 + + +def test_load_settings_ignores_bad_env_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + monkeypatch.setenv("SELCTEL_REFRESH_INTERVAL", "5") + settings = load_settings() + assert settings.refresh_interval == 60 + + +def test_credentials_defaults() -> None: + creds = Credentials() + assert creds.service_user == "" + assert creds.static_token == "" + + +def test_settings_env_api_url(monkeypatch, tmp_path) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + monkeypatch.setenv("SELCTEL_API_URL", "https://api.example.test") + settings = load_settings() + assert settings.api_base_url == "https://api.example.test" + + +def test_credentials_env(monkeypatch, tmp_path) -> None: + cred_file = tmp_path / "credentials.json" + monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file) + monkeypatch.setenv("SELCTEL_TOKEN", "tok123") + monkeypatch.setenv("SELCTEL_SERVICE_USER", "svc-user") + monkeypatch.setenv("SELCTEL_DOMAIN_ID", "239633") + creds = load_credentials() + assert creds.static_token == "tok123" + assert creds.service_user == "svc-user" + assert creds.domain_id == "239633" + + +def test_credentials_file_precedence(monkeypatch, tmp_path) -> None: + cred_file = tmp_path / "credentials.json" + monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file) + save_credentials(Credentials(static_token="from-file", service_user="file-user")) + monkeypatch.setenv("SELCTEL_TOKEN", "from-env") + creds = load_credentials() + # env имеет приоритет над файлом + assert creds.static_token == "from-env" + assert creds.service_user == "file-user" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ca07ce3 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,215 @@ +"""Unit-тесты pydantic-моделей данных.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from selectel_ml_tui.models import ( + Consumption, + ConsumptionItem, + HistoryPoint, + Metrics, + ModelMetrics, + ProjectRef, + format_percent, + format_rub, + format_tokens, +) + + +class TestConsumption: + def test_parse_with_z_timestamp(self) -> None: + item = Consumption.model_validate( + { + "period": "2026-08-01T00:00:00.000Z", + "amount_rub": "854.83", + "input_tokens": 100, + "output_tokens": 50, + "cache_read_tokens": 10, + "cache_write_tokens": 5, + } + ) + assert item.period == datetime(2026, 8, 1, tzinfo=timezone.utc) + assert item.amount_rub == Decimal("854.83") + assert item.input_tokens == 100 + + def test_parse_naive_timestamp(self) -> None: + item = Consumption.model_validate({"period": "2026-08-01T00:00:00"}) + assert item.period == datetime(2026, 8, 1) + assert item.amount_rub == Decimal("0") + assert item.input_tokens == 0 + + def test_negative_amount_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate( + {"period": "2026-08-01T00:00:00", "amount_rub": "-1"} + ) + + def test_negative_tokens_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate( + { + "period": "2026-08-01T00:00:00", + "input_tokens": -5, + } + ) + + def test_invalid_date_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate({"period": "not-a-date"}) + + +class TestMetrics: + def test_parse_metrics(self) -> None: + metrics = Metrics.model_validate( + { + "availability_pct": 99.912, + "avg_ttft_ms": 0.45, + "input_tokens": 1000, + "output_tokens": 900, + "cache_read_tokens": 800, + "cache_write_tokens": 700, + } + ) + assert metrics.availability_pct == 99.912 + assert metrics.avg_ttft_ms == 0.45 + assert metrics.input_tokens == 1000 + + def test_missing_fields_defaults(self) -> None: + metrics = Metrics.model_validate({}) + assert metrics.availability_pct == 100.0 + assert metrics.input_tokens == 0 + assert metrics.avg_ttft_ms == 0.0 + + def test_availability_bounds_rejected(self) -> None: + with pytest.raises(ValidationError): + Metrics.model_validate({"availability_pct": 101}) + with pytest.raises(ValidationError): + Metrics.model_validate({"availability_pct": -1}) + + +class TestModelMetrics: + def test_parse_model_metrics(self) -> None: + model = ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "availability_pct": 99.5, + "avg_ttft_ms": 0.3, + } + ) + assert model.model_id == "gpt-4o" + assert model.model_name == "GPT-4o" + assert model.availability_pct == 99.5 + + +class TestHistoryPoint: + def test_parse(self) -> None: + point = HistoryPoint.model_validate( + { + "timestamp": "2026-08-01T12:00:00Z", + "amount_rub": "12.5", + "tokens": { + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + }, + } + ) + assert point.timestamp == datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc) + assert point.amount_rub == Decimal("12.5") + assert point.tokens.output_tokens == 200 + + def test_missing_tokens_defaults(self) -> None: + point = HistoryPoint.model_validate({"timestamp": "2026-08-01T00:00:00"}) + assert point.tokens.input_tokens == 0 + assert point.amount_rub == Decimal("0") + + +class TestConsumptionItem: + def _base(self, **overrides: object) -> dict[str, object]: + data: dict[str, object] = { + "account_id": "239633", + "period": "2026-08-01T00:00:00", + "provider_key": "aig", + "value": 854.83, + } + data.update(overrides) + return data + + def test_project_as_string(self) -> None: + item = ConsumptionItem.model_validate( + self._base(project="7bf2d2a328494e25b13d05a1926ca75a") + ) + assert item.project_id == "7bf2d2a328494e25b13d05a1926ca75a" + + def test_project_as_object(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + project={"id": "proj-1", "name": "239633"}, + ) + ) + assert isinstance(item.project, ProjectRef) + assert item.project_id == "proj-1" + assert item.project.name == "239633" + + def test_project_null(self) -> None: + item = ConsumptionItem.model_validate(self._base(project=None)) + assert item.project_id is None + + def test_object_as_object(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + object={ + "id": "obj-1", + "name": "key-1", + "type": "ai_api_key", + "type_name": "Api ключ", + } + ) + ) + assert item.object_id == "obj-1" + assert item.object.type == "ai_api_key" # type: ignore[union-attr] + + def test_object_as_string(self) -> None: + item = ConsumptionItem.model_validate(self._base(object="obj-1")) + assert item.object_id == "obj-1" + + def test_metric_info(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + metric={ + "id": "air_tokens_cost", + "name": "Стоимость", + "unit": "nanorub", + "quantity": "854857248637", + } + ) + ) + assert item.metric is not None + assert item.metric.id == "air_tokens_cost" + assert item.metric.quantity == Decimal("854857248637") + + def test_value_decimal(self) -> None: + item = ConsumptionItem.model_validate(self._base(value=42.5)) + assert item.value == Decimal("42.5") + + +class TestFormatting: + def test_format_rub(self) -> None: + assert format_rub(Decimal("854.83")) == "854,83 ₽" + assert format_rub(0) == "0,00 ₽" + assert format_rub("1234.5") == "1 234,50 ₽" + + def test_format_tokens(self) -> None: + assert format_tokens(0) == "0" + assert format_tokens(1234567) == "1 234 567" + + def test_format_percent(self) -> None: + assert format_percent(99.912) == "99,9%" + assert format_percent(100.0) == "100,0%" diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..583bed5 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,43 @@ +"""Тесты платформенно-зависимой диагностики (platform.py).""" + +from __future__ import annotations + +from selectel_ml_tui import platform as pl + + +def test_platform_info_fields(monkeypatch) -> None: + monkeypatch.setenv("TERM", "xterm-256color") + monkeypatch.setenv("LANG", "ru_RU.UTF-8") + info = pl.get_platform_info() + assert info.system in ("Linux", "Darwin", "Windows") + assert info.term == "xterm-256color" + assert info.lang == "ru_RU.UTF-8" + assert info.utf8 is True + assert info.python_version + + +def test_check_environment_ok(monkeypatch) -> None: + monkeypatch.setenv("TERM", "tmux-256color") + monkeypatch.setenv("LANG", "en_US.UTF-8") + results = {c.name: c for c in pl.check_environment()} + assert all(r.ok for r in results.values()) + assert results["python"].ok + + +def test_check_environment_warns_without_term_and_lang(monkeypatch) -> None: + monkeypatch.delenv("TERM", raising=False) + monkeypatch.delenv("LANG", raising=False) + results = {c.name: c for c in pl.check_environment()} + assert not results["TERM"].ok + assert not results["LANG"].ok + + +def test_check_environment_warns_wrong_term(monkeypatch) -> None: + monkeypatch.setenv("TERM", "dumb") + monkeypatch.setenv("LANG", "C") + results = {c.name: c for c in pl.check_environment()} + assert not results["TERM"].ok + + +def test_min_python_version() -> None: + assert pl.MIN_PYTHON == (3, 10) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..f293447 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,324 @@ +"""Unit-тесты сервисного слоя.""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from typing import Any + +import httpx + +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import Consumption, Metrics +from selectel_ml_tui.services import ( + DashboardData, + DataService, + parse_keys, + parse_metrics, + parse_models, + parse_overview, + parse_tokens, +) +from selectel_ml_tui.storage import Storage + + +def _summary_response(value: str = "85483") -> dict[str, Any]: + return { + "status": "success", + "data": [{"value": value}], + } + + +async def test_parse_tokens() -> None: + tokens = parse_tokens( + {"input_tokens": 10, "output_tokens": 20, "cache_read": 30, "cache_write": 40} + ) + assert tokens.input_tokens == 10 + assert tokens.output_tokens == 20 + assert tokens.cache_read_tokens == 30 + assert tokens.cache_write_tokens == 40 + + +async def test_parse_tokens_missing_fields_defaults() -> None: + tokens = parse_tokens({}) + assert tokens.input_tokens == 0 + assert tokens.output_tokens == 0 + assert tokens.cache_read_tokens == 0 + assert tokens.cache_write_tokens == 0 + + +async def test_parse_metrics() -> None: + metrics = parse_metrics( + { + "global": { + "requests": 1035, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.67, + "avg_latency_seconds": 11.85, + "tokens": { + "input": 228542299, + "output": 747268, + "cached_input": 222355840, + "cache_write": 0, + }, + } + } + ) + assert metrics is not None + assert metrics.availability_pct == 100 + assert metrics.avg_ttft_ms == 3670 + assert metrics.avg_latency_ms == 11850 + assert metrics.requests == 1035 + assert metrics.input_tokens == 228542299 + assert metrics.output_tokens == 747268 + assert metrics.cache_read_tokens == 222355840 + assert metrics.cache_write_tokens == 0 + + +async def test_parse_metrics_empty() -> None: + assert parse_metrics({}) is None + + +async def test_parse_models() -> None: + models = parse_models( + { + "by_model": [ + { + "model": "deepseek/deepseek-v4-flash", + "requests": 69, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.35, + }, + { + "model": "deepseek/deepseek-v4-pro", + "requests": 966, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.69, + }, + ] + } + ) + assert len(models) == 2 + assert models[0].model_name == "deepseek/deepseek-v4-flash" + assert models[0].requests == 69 + assert models[0].availability_pct == 100 + assert models[0].avg_ttft_ms == 3350 + assert models[1].model_id == "deepseek/deepseek-v4-pro" + + +async def test_parse_overview() -> None: + import datetime as _dt + + consumption = parse_overview( + { + "totals": { + "requests": 1034, + "cost_rub_nano": "1075311673934", + "tokens": { + "input": 228314832, + "output": 746702, + "cached_input": 222128640, + "cache_write": 0, + }, + "tokens_total": {"total": 451607518}, + } + }, + _dt.datetime(2026, 8, 19, tzinfo=_dt.timezone.utc), + ) + assert consumption.amount_rub == Decimal("1075.311673934") + assert consumption.requests == 1034 + assert consumption.tokens_total == 451607518 + assert consumption.input_tokens == 228314832 + assert consumption.cache_read_tokens == 222128640 + + +async def test_parse_keys() -> None: + keys = parse_keys( + [ + { + "name": "Mei", + "prefix": "sk-sl-v1-9dd61ec", + "is_active": True, + "budget": { + "spend_limit_rub_nano": "1300000000000", + "current_usage": {"spend_rub_nano": "1082949415164"}, + }, + }, + { + "name": "Ollama Mac Air", + "prefix": "sk-sl-v1-65fdb2e", + "is_active": False, + "budget": {"spend_limit_rub_nano": "100000000000"}, + }, + ] + ) + assert len(keys) == 2 + assert keys[0].name == "Mei" + assert keys[0].is_active is True + assert keys[0].spend_limit_rub == Decimal("1300") + assert keys[0].spend_rub == Decimal("1082.949415164") + assert keys[0].remaining_rub == Decimal("217.050584836") + assert keys[1].is_active is False + assert keys[1].spend_limit_rub == Decimal("100") + + +async def test_refresh_billing_only(tmp_path) -> None: + """Только billing (без IAM): стоимость за месяц есть, gateway отмечен.""" + settings = Settings() + creds = Credentials(static_token="static-token") + storage = Storage(tmp_path / "cache.db") + seen_end: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if "summary_stats" in request.url.path: + seen_end["summary"] = request.url.params.get("end", "") + return httpx.Response(200, json=_summary_response()) + if "consumption" in request.url.path: + seen_end["consumption"] = request.url.params.get("end", "") + return httpx.Response( + 200, + json={ + "status": "success", + "data": [{"period": "2026-08-19T00:00:00", "value": "7133"}], + }, + ) + return httpx.Response(200, json=_summary_response()) + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("854.83") + # Пункт A: end должен содержать время (не усечённую дату) и быть naive + # (без смещения часового пояса — иначе API отвечает 422). + assert "T" in seen_end["summary"] + assert "T" in seen_end["consumption"] + assert "+" not in seen_end["summary"] + # Пункт B: дневная стоимость сегодня выделена отдельно. + assert data.today_amount_rub == Decimal("71.33") + assert not data.offline + assert data.error is not None + assert "сервисный пользователь" in data.error + assert storage.get_latest_consumption() is not None + history = storage.get_history(days=30) + assert len(history) == 1 + # История хранит дневную стоимость (не месячную сумму). + assert history[0].amount_rub == Decimal("71.33") + + +async def test_refresh_billing_failure_uses_cache(tmp_path) -> None: + """Сбой billing + кэш → офлайн-данные из Storage.""" + settings = Settings() + creds = Credentials(static_token="static-token") + storage = Storage(tmp_path / "cache.db") + storage.save_snapshot( + consumption=Consumption( + period=dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc), + amount_rub=Decimal("10.00"), + ) + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="boom") + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.offline is True + assert data.source == "cache" + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("10.00") + assert data.error is not None + + +async def test_refresh_with_gateway(tmp_path) -> None: + """Billing + gateway (IAM): заполнены метрики, модели, ключи, бюджет.""" + settings = Settings() + creds = Credentials( + static_token="static-token", + service_user="svc", + password="pwd", + project_id="proj-1", + domain_id="239633", + ) + storage = Storage(tmp_path / "cache.db") + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/auth/tokens"): + return httpx.Response( + 201, + headers={"X-Subject-Token": "iam-token"}, + json={"token": {"expires_at": "2030-01-01T00:00:00Z"}}, + ) + if path.endswith("/gateways"): + return httpx.Response(200, json={"data": [{"id": "gw-1"}]}) + if path.endswith("/overview"): + return httpx.Response( + 200, + json={ + "totals": { + "requests": 1034, + "cost_rub_nano": "1075311673934", + "tokens": {"input": 100, "output": 200}, + "tokens_total": {"total": 300}, + }, + "budget": { + "spend_limit_rub_nano": "2000000000000", + "spend_rub_nano": "1077874156239", + }, + }, + ) + if path.endswith("/metrics"): + return httpx.Response( + 200, + json={ + "global": {"success_rate_pct": 99.5, "avg_ttft_seconds": 0.3}, + "by_model": [ + {"model": "deepseek/deepseek-v4-flash", "requests": 69} + ], + }, + ) + if path.endswith("/keys"): + return httpx.Response( + 200, + json={ + "data": [ + { + "name": "Mei", + "prefix": "sk-sl-v1-9dd61ec", + "is_active": True, + "budget": { + "spend_limit_rub_nano": "1300000000000", + "current_usage": {"spend_rub_nano": "1082949415164"}, + }, + } + ] + }, + ) + return httpx.Response(200, json=_summary_response()) + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("1075.311673934") + assert data.metrics is not None + assert data.metrics.availability_pct == 99.5 + assert data.metrics.avg_ttft_ms == 300 + assert len(data.models) == 1 + assert data.models[0].model_name == "deepseek/deepseek-v4-flash" + assert len(data.keys) == 1 + assert data.keys[0].name == "Mei" + assert data.budget is not None + assert data.budget.spend_limit_rub == Decimal("2000") + + +async def test_dashboard_data_has_data() -> None: + empty = DashboardData() + assert not empty.has_data + full = DashboardData(metrics=Metrics()) + assert full.has_data diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..1b7ad21 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,238 @@ +"""Unit-тесты SQLite-кэша и истории.""" + +from __future__ import annotations + +import datetime as dt +import json +import sqlite3 +from decimal import Decimal + +import pytest + +from selectel_ml_tui.models import ( + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.storage import Storage, default_db_path + + +def _consumption(period: str, amount: str) -> Consumption: + return Consumption.model_validate( + { + "period": period, + "amount_rub": amount, + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + } + ) + + +def _metrics() -> Metrics: + return Metrics.model_validate( + { + "availability_pct": 99.9, + "avg_ttft_ms": 0.4, + "input_tokens": 1000, + "output_tokens": 900, + "cache_read_tokens": 800, + "cache_write_tokens": 700, + } + ) + + +def _models() -> list[ModelMetrics]: + return [ + ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "availability_pct": 99.9, + "avg_ttft_ms": 0.3, + } + ), + ModelMetrics.model_validate({"model_id": "llama-3", "model_name": "Llama 3"}), + ] + + +def test_default_db_path(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("selectel_ml_tui.storage.data_dir", lambda: tmp_path) + assert default_db_path() == tmp_path / "cache.db" + assert default_db_path().parent == tmp_path + + +def test_save_and_get_latest_consumption(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + item = _consumption("2026-08-01T00:00:00", "854.83") + storage.save_consumption(item) + latest = storage.get_latest_consumption() + assert latest is not None + assert latest.amount_rub == Decimal("854.83") + assert latest.period == dt.datetime(2026, 8, 1) + assert latest.input_tokens == 100 + storage.close() + + +def test_get_latest_empty(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + assert storage.get_latest_consumption() is None + assert storage.get_latest_metrics() is None + assert storage.get_latest_model_metrics() == [] + storage.close() + + +def test_save_and_get_latest_metrics(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_metrics(_metrics()) + latest = storage.get_latest_metrics() + assert latest is not None + assert latest.availability_pct == 99.9 + assert latest.input_tokens == 1000 + storage.close() + + +def test_save_and_get_latest_model_metrics(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_model_metrics(_models()) + latest = storage.get_latest_model_metrics() + assert len(latest) == 2 + assert {m.model_id for m in latest} == {"gpt-4o", "llama-3"} + gpt = next(m for m in latest if m.model_id == "gpt-4o") + assert gpt.availability_pct == 99.9 + storage.close() + + +def test_history_upsert_same_day(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + point = HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("10.00"), + tokens=TokenUsage(input_tokens=5), + ) + storage.save_history(point) + point2 = HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("15.00"), + tokens=TokenUsage(input_tokens=8), + ) + storage.save_history(point2) + history = storage.get_history(days=30) + assert len(history) == 1 # upsert, без дублей + assert history[0].amount_rub == Decimal("15.00") + storage.close() + + +def test_history_days_filter(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + now = dt.datetime.now(dt.timezone.utc) + storage.save_history( + HistoryPoint(timestamp=now, amount_rub=Decimal("1"), tokens=TokenUsage()) + ) + storage.save_history( + HistoryPoint( + timestamp=now - dt.timedelta(days=30), + amount_rub=Decimal("2"), + tokens=TokenUsage(), + ) + ) + assert len(storage.get_history(days=7)) == 1 + assert len(storage.get_history(days=60)) == 2 + storage.close() + + +def test_save_snapshot_combined(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_snapshot( + consumption=_consumption("2026-08-01T00:00:00", "854.83"), + metrics=_metrics(), + models=_models(), + history_point=HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ), + ) + assert storage.get_latest_consumption() is not None + assert storage.get_latest_metrics() is not None + assert len(storage.get_latest_model_metrics()) == 2 + assert len(storage.get_history(days=30)) == 1 + storage.close() + + +def test_cleanup_removes_old(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + old = dt.datetime(2020, 1, 1, 0, 0) + storage.save_history( + HistoryPoint(timestamp=old, amount_rub=Decimal("1"), tokens=TokenUsage()) + ) + storage.save_consumption(_consumption("2020-01-01T00:00:00", "1")) + deleted = storage.cleanup(max_age_days=30) + # history удаляется по timestamp; consumption_snapshots — по created_at (сейчас) + assert deleted == 1 + assert storage.get_history(days=3650) == [] + assert storage.get_latest_consumption() is not None + storage.close() + + +def test_export_json(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100, output_tokens=200), + ) + ) + out = tmp_path / "history.json" + storage.export_json(out) + data = json.loads(out.read_text(encoding="utf-8")) + assert len(data) == 1 + assert data[0]["amount_rub"] == "854.83" + assert data[0]["input_tokens"] == 100 + storage.close() + + +def test_export_csv(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100, output_tokens=200), + ) + ) + out = tmp_path / "history.csv" + storage.export_csv(out) + lines = out.read_text(encoding="utf-8").strip().splitlines() + assert lines[0].startswith("timestamp,amount_rub") + assert "854.83" in lines[1] + assert "100" in lines[1] + storage.close() + + +def test_close_idempotent(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.close() + with pytest.raises(sqlite3.ProgrammingError): + storage.get_history() + + +def test_clear_removes_all_rows(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18), + amount_rub=Decimal("1"), + tokens=TokenUsage(input_tokens=1), + ) + ) + storage.save_consumption(Consumption(period="2026-08-01T00:00:00")) + assert storage.get_history() != [] + deleted = storage.clear() + assert deleted >= 1 + assert storage.get_history() == [] + assert storage.get_latest_consumption() is None diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..e10f13f --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,296 @@ +"""Headless-тесты TUI (Textual pilot).""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal + +from textual.widgets import DataTable, Input, Static + +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import ( + ApiKeyUsage, + BudgetInfo, + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.services import DashboardData +from selectel_ml_tui.storage import Storage +from selectel_ml_tui.ui.app import SelectelMLTUI + + +class FakeService: + """Сервис-заглушка: возвращает готовые данные.""" + + def __init__(self, data: DashboardData) -> None: + self.data = data + self.refresh_count = 0 + + async def refresh(self) -> DashboardData: + self.refresh_count += 1 + return self.data + + +def _dashboard_data() -> DashboardData: + return DashboardData( + month_consumption=Consumption.model_validate( + { + "period": "2026-08-01T00:00:00", + "amount_rub": "854.83", + "requests": 1034, + "tokens_total": 451607518, + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + } + ), + metrics=Metrics.model_validate( + { + "availability_pct": 99.9, + "avg_ttft_ms": 0.4, + "avg_latency_ms": 1.2, + "requests": 1035, + "input_tokens": 5, + } + ), + models=[ + ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "requests": 69, + "availability_pct": 100.0, + "avg_ttft_ms": 3.35, + } + ) + ], + keys=[ + ApiKeyUsage( + name="Mei", + prefix="sk-sl-v1-9dd61ec", + is_active=True, + spend_limit_rub=Decimal("1300"), + spend_rub=Decimal("1082.95"), + remaining_rub=Decimal("217.05"), + ) + ], + budget=BudgetInfo( + spend_limit_rub=Decimal("2000"), + spend_rub=Decimal("1077.87"), + remaining_rub=Decimal("922.13"), + ), + ) + + +def _make_app(tmp_path, data: DashboardData | None = None) -> SelectelMLTUI: + settings = Settings(refresh_interval=3600) + creds = Credentials(static_token="tok") + storage = Storage(tmp_path / "cache.db") + service = FakeService(data or _dashboard_data()) + return SelectelMLTUI( + settings=settings, + credentials=creds, + storage=storage, + service=service, + ) + + +async def test_dashboard_renders_data(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + month = app.screen.query_one("#month-panel", Static) + rendered = str(month.render()) + assert "854,83" in rendered + assert "Потребление за месяц" in rendered + metrics = app.screen.query_one("#metrics-panel", Static) + assert "99,9%" in str(metrics.render()) + table = app.screen.query_one("#models-table", DataTable) + assert table.row_count == 1 + assert "GPT-4o" in table.get_row_at(0) + + +async def test_refresh_hotkey_reruns_service(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + service = app._service + assert service.refresh_count == 1 + await pilot.press("r") + await pilot.pause() + assert service.refresh_count == 2 + + +async def test_navigate_to_history_and_settings(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + # history + await pilot.press("h") + await pilot.pause() + assert app.screen.__class__.__name__ == "HistoryScreen" + # назад + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + # settings + await pilot.press("s") + await pilot.pause() + assert app.screen.__class__.__name__ == "SettingsScreen" + info = app.screen.query_one("#settings-info", Static).render() + assert "API base URL" in str(info) + assert "tok" in str(info) + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + + +async def test_history_screen_shows_points(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18, 0, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("h") + await pilot.pause() + table = app.screen.query_one("#history-table", DataTable) + assert table.row_count == 1 + cells = [str(cell) for cell in table.get_row_at(0)] + assert any("854,83" in cell for cell in cells) + bars = app.screen.query_one("#history-bars") + assert bars.render() != "Нет данных для графика" + + +async def test_history_period_switch(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + for day in range(20): + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, day + 1, 0, 0), + amount_rub=Decimal(str(day + 1)), + tokens=TokenUsage(input_tokens=day + 1), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("h") + await pilot.pause() + title = app.screen.query_one("#history-title", Static) + assert "30 дней" in str(title.render()) + table = app.screen.query_one("#history-table", DataTable) + rows_30 = table.row_count + await pilot.click("#period-7") + await pilot.pause() + title = app.screen.query_one("#history-title", Static) + assert "7 дней" in str(title.render()) + table = app.screen.query_one("#history-table", DataTable) + assert table.row_count < rows_30 + await pilot.click("#period-90") + await pilot.pause() + assert "90 дней" in str(app.screen.query_one("#history-title", Static).render()) + assert app.screen.query_one("#history-table", DataTable).row_count >= rows_30 + + +async def test_model_detail_on_enter(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert app.screen.__class__.__name__ == "ModelDetailScreen" + detail = app.screen.query_one("#model-detail", Static) + rendered = str(detail.render()) + assert "GPT-4o" in rendered + assert "Запросы" in rendered + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + + +async def test_settings_save_interval(tmp_path, monkeypatch) -> None: + saved = {} + + def fake_save(settings) -> None: + saved["interval"] = settings.refresh_interval + + monkeypatch.setattr("selectel_ml_tui.ui.app.save_settings", fake_save) + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + inp = app.screen.query_one("#interval-input", Input) + inp.value = "120" + await pilot.click("#save-settings") + await pilot.pause() + assert app._settings.refresh_interval == 120 + assert saved["interval"] == 120 + + +async def test_settings_clear_cache(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18, 0, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + await pilot.click("#clear-cache") + await pilot.pause() + assert storage.get_history(days=30) == [] + + +async def test_offline_status_in_subtitle(tmp_path) -> None: + data = _dashboard_data() + data.offline = True + app = _make_app(tmp_path, data) + async with app.run_test() as pilot: + await pilot.pause() + assert "offline" in app.sub_title + + +async def test_window_resize(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + await pilot.resize_terminal(80, 24) + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + month = app.screen.query_one("#month-panel", Static) + assert "854,83" in str(month.render()) + + +async def test_keys_screen(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("k") + await pilot.pause() + assert app.screen.__class__.__name__ == "KeysScreen" + table = app.screen.query_one("#keys-table", DataTable) + assert table.row_count == 1 + cells = [str(cell) for cell in table.get_row_at(0)] + assert any("Mei" in cell for cell in cells) + assert any("1 300" in cell for cell in cells) + budget = app.screen.query_one("#keys-budget", Static) + assert "Бюджет роутера" in str(budget.render()) + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..19b007e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1118 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +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/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[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 = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[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 = "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 = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[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.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[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 = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { 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/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[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 = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.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/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "selectel-ml-tui" +version = "1.0.2" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "textual" }, + { name = "tomli" }, + { name = "tomli-w" }, +] + +[package.optional-dependencies] +build = [ + { name = "pyinstaller" }, +] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic-settings", specifier = ">=2.3" }, + { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, + { name = "textual", specifier = ">=2.0" }, + { name = "tomli", specifier = ">=2.0" }, + { name = "tomli-w", specifier = ">=1.0" }, +] +provides-extras = ["dev", "build"] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +]