26 Commits

Author SHA1 Message Date
c5b241ae02 release: версия 0.0.10
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 01:43:24 +03:00
356bc57a2a docs(monitoring): описать контейнерный экспортер и модель безопасности
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый раздел про container-exporter/docker-socket-proxy: зачем свой
экспортер вместо cAdvisor, какие метрики отдаёт, почему доступ к
докер-сокету через прокси безопасен. Дополнена таблица метрик backend
(vidconf_host_info).
2026-07-28 01:40:04 +03:00
afd4daee65 feat(backend): метрика vidconf_host_info для плашки характеристик сервера
Info-метрика (значение всегда 1, лейблы cpus/ram_mb/gpu_name/vram_mb) —
источник GPU для новой панели «Характеристики сервера» в дашборде «Хост
и контейнеры» (живые CPU/RAM/диск там же берутся из node-exporter,
GPU node-exporter не знает). Данные — уже читаемые Settings.hw_* из .env,
которые install.sh пишет по ADR-004. «—» вместо None/пустой строки —
однозначный прочерк на панели вместо пустого текста.
2026-07-28 01:39:58 +03:00
ace5bf7a0d feat(monitoring): экспортер метрик по контейнерам поверх Docker Engine API
Разбивка по контейнерам (CPU/память/сеть) — то, что не смог дать
cAdvisor из-за несовместимости с containerd-снапшоттером сервера 1gb
(убран в 7f5c888). Свой минимальный сервис container-exporter (Python/
aiohttp/prometheus_client) опрашивает Docker Engine API параллельно в
фоновой задаче, не завязываясь на scrape-интервал Prometheus.

Доступ к докер-сокету изолирован через docker-socket-proxy: экспортеру
разрешены только GET /containers/json и /containers/*/stats, любые
изменяющие запросы блокируются на уровне прокси (POST=0) — полная
компрометация экспортера не даёт управлять Docker. Ни один из двух
сервисов не публикует портов наружу.

В host.json возвращены панели «Топ контейнеров по CPU/памяти» на новых
метриках vidconf_container_* (в 0.0.9 их убрали вместе с cAdvisor).
2026-07-28 01:39:42 +03:00
7f5c88869a fix(monitoring): убрать cAdvisor — несовместим с containerd-снапшоттером
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На сервере 1gb Docker Engine использует containerd-снапшоттер
(driver-type: io.containerd.snapshotter.v1), а не классический overlay2.
cAdvisor (проверено на v0.49.2 и свежей v0.52.1, с --docker_only и через
--containerd/--containerd-namespace=moby) не может определить read-write
layer контейнеров — падает с «failed to identify the read-write layer
ID», метрики только по корневому cgroup, без разбивки по контейнерам.
Открытая проблема совместимости, флагами не решается.

Убран сервис cadvisor, job в prometheus.yml, панели «топ контейнеров» в
host.json (Prometheus иначе резолвит cadvisor:8080 в никуда — Grafana
показывала бы «No data» вечно). node-exporter метрики хоста (CPU/RAM/
диск/сеть) при этом покрывает полностью, без изменений.

Правка конфигурации мониторинга, без изменения пользовательского
поведения — версия не бампается.
2026-07-28 00:42:25 +03:00
54fd1a26d7 release: версия 0.0.9
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 00:26:40 +03:00
456cc58b25 docs(monitoring): описать node-exporter/cAdvisor и алерты по железу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Документация мониторинга описывала только старые компоненты (prometheus,
postgres/redis-exporter, дашборд пайплайнов) — актуализирована под
node-exporter/cAdvisor, дашборд host.json и три новых алерта.
2026-07-28 00:22:08 +03:00
dccc369d0b fix(admin): дефолтные подвкладки конференций и пользователей
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Конференции открывались на «Все» — неинформативная сборная вкладка вместо
актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец
списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального
к общему.

Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию
видел вперемешку с заблокированными.
2026-07-28 00:21:06 +03:00
77ee26014d feat(monitoring): метрики CPU/RAM/диска хоста и контейнеров
Дашборд «Пайплайны пост-обработки» покрывал только прикладную логику —
нехватка памяти/CPU на сервере была видна только косвенно, по латентности API.

Добавлены node-exporter (метрики хоста) и cAdvisor (метрики по контейнерам,
профиль monitoring) — оба без публикации портов наружу, Prometheus ходит
к ним по внутренней сети compose. Новый дашборд host.json («Хост и
контейнеры») и три алерта (HostMemoryLow/HostDiskLow/HostCpuHigh) с
порогами под сервер 1gb (8 ГБ RAM, 4 CPU, 50 ГБ диска).
2026-07-28 00:21:01 +03:00
413789ba22 release: версия 0.0.8
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 21:10:08 +03:00
c4721fcf99 feat(admin): поиск пользователей по названию команды
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
2026-07-27 21:04:26 +03:00
09215da22a feat(admin): вкладки фильтра по статусу в списке пользователей
Задача 3 переопределена оператором: вместо окончательного удаления
пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages,
требующие миграции схемы — решили отложить) добавлены вкладки
«Активные»/«Заблокированные»/«Все» перед полем поиска в админке —
список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
2026-07-27 20:59:29 +03:00
25ffd9e678 feat(auth): несколько эталонных mail-доменов для верификации регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
2026-07-27 20:40:23 +03:00
e55a6aebe7 fix(admin): не блокировать сохранение настроек недоступным уровнем AI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Валидация ai_level срабатывала на любой PUT /admin/settings, включая
случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень —
на слабом железе это намертво блокировало правку любых других настроек.
Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет
доступность только при фактической смене уровня; фронт дополнительно
отправляет в PUT только реально изменённые поля.
2026-07-27 20:25:31 +03:00
4b92f89efe release: версия 0.0.7
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 17:42:03 +03:00
b11feac529 feat(room): компактная шторка настроек устройств на мобильном
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же
поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/
`room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее
с телефона. Десктоп не меняется.

Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через
существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение
пальца и либо закрывает панель, либо возвращает её на место пружиной).
2026-07-27 17:32:42 +03:00
e07217657f feat(room): выбор устройства аудиовывода в настройках
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом
с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind:
'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices
(LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново
при следующем подключении через RoomOptions.audioOutput.

Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent:
там, где браузер не умеет переключать вывод (iOS Safari), список скрыт,
вместо него — подсказка, что маршрутизация звука на стороне системы.
2026-07-27 17:31:35 +03:00
35a1455645 refactor(auth): убрать строку «self-hosted» из брендовой панели
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Копирайт целиком переехал в общий футер (решение оператора), в панели
строка больше не нужна. У `.brand-panel` осталось два потомка вместо трёх,
поэтому `.brand-copy` получил `margin-block: auto` — иначе
`justify-content: space-between` прижал бы текст к низу панели.
2026-07-27 01:53:27 +03:00
cfb3a941ab release: версия 0.0.6
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 01:34:03 +03:00
f3556d7d89 fix(calendar): убрать «г.» из подписей дней в сетке
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.

Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).

Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
2026-07-27 01:32:02 +03:00
b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00
5daff3b7af feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:

- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
  вопреки говорящим, но уступает любой активной демонстрации экрана; как
  только демонстрация закончилась, фокус возвращается именно на
  закреплённого (правило стоит выше удержания предыдущего фокуса).
  Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
  из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
  булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
  (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.

Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).

`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
2026-07-27 01:31:42 +03:00
28b87a8c95 release: версия 0.0.5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 00:56:37 +03:00
b35209a775 feat(admin): контактный адрес и тестовое письмо в настройках админки
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
2026-07-27 00:35:59 +03:00
84b06f78f1 test(email): контактный адрес, Reply-To и тестовая отправка письма
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
2026-07-27 00:35:53 +03:00
74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00
56 changed files with 3726 additions and 249 deletions

View File

@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.4
VIDCONF_VERSION=0.0.10
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,139 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.10] — 2026-07-28
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
### Добавлено
- Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
`cAdvisor`, несовместимого с containerd-снапшоттером Docker Engine на
сервере `1gb` (убран в `0.0.9`). Свой минимальный сервис
`container-exporter` (`deploy/monitoring/container-exporter/`) опрашивает
Docker Engine API параллельно в фоновой задаче, независимо от
storage-driver'а. Доступ к докер-сокету изолирован через
`docker-socket-proxy` — экспортеру разрешены только `GET`-запросы по
контейнерам, любые изменяющие запросы блокируются на уровне прокси.
Панели «Топ контейнеров по CPU/памяти» вернулись в дашборд
«Хост и контейнеры».
- Плашка «Характеристики сервера» в том же дашборде: живые «Ядра CPU» /
«Объём RAM» / «Диск (/)» из `node-exporter`, GPU — из новой info-метрики
backend `vidconf_host_info` (данные `install.sh`, `HW_*` в `.env`).
## [0.0.9] — 2026-07-28
Метрики CPU/RAM/диска хоста в Grafana; дефолтные подвкладки конференций и
пользователей в админке.
### Добавлено
- Мониторинг железа: `node-exporter` (CPU/RAM/диск/сеть/load average хоста)
— профиль compose `monitoring`, без публикации портов наружу. Новый
дашборд Grafana «Хост и контейнеры». Три новых алерта Prometheus:
`HostMemoryLow`, `HostDiskLow`, `HostCpuHigh` (пороги — под сервер `1gb`:
8 ГБ RAM, 4 CPU, 50 ГБ диска). `cAdvisor` (разбивка по контейнерам)
сознательно не включён — несовместим с containerd-снапшоттером Docker
Engine на `1gb`, см. `docs/deploy/monitoring.md`.
### Изменено
- Админка, вкладка «Конференции»: вкладка «Все» перенесена в конец списка
фильтров (после «Завершённые»), по умолчанию открываются «Запланированные».
- Админка, вкладка «Пользователи»: по умолчанию открываются «Активные»
(было «Все»).
## [0.0.8] — 2026-07-27
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
списке пользователей, исправлен баг сохранения настроек.
### Исправлено
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
доступность уровня проверяется только при его фактической смене; фронт
отправляет в запросе лишь реально изменённые поля.
### Добавлено
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
доменов вместо одного — регистрация проходит с email с любого домена из
списка. Старое значение из БД (один домен) читается без миграции и
переписывается в новый формат при первом же сохранении.
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
«Все» перед полем поиска.
- Поиск по пользователям учитывает и название команды — пользователи без
команды по-прежнему остаются в общей выдаче.
## [0.0.7] — 2026-07-27
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
### Добавлено
- Выбор устройства аудиовывода (динамики/наушники/Bluetooth-гарнитура) в
«Настройках устройств» рядом с микрофоном и камерой. Выбор переживает
перезаход в комнату. Список показывается только там, где браузер умеет
им управлять (Android Chrome и другие Chromium на десктопе/мобильном);
в iOS Safari поддержки нет вовсе — вместо списка короткая подсказка, что
переключение звука там на стороне системы.
- На мобильной ширине «Настройки устройств» открываются компактной шторкой
снизу вместо модалки по центру экрана — закрывается свайпом вниз, тапом
вне панели или Escape. На десктопе поведение не изменилось.
### Примечания по эксплуатации
- Управлять маршрутизацией звука (принудительно включить громкую связь или
перекинуть звук на Bluetooth) из браузера нельзя в принципе — это делает
ОС, веб-страница может только выбрать устройство вывода там, где браузер
это позволяет (`setSinkId`).
## [0.0.6] — 2026-07-27
Управление фокусом основного окна конференции: участника можно закрепить, а
без закрепления окно само следует за говорящим. Плюс общий футер на всех
страницах сервиса.
### Добавлено
- Закрепление участника в основном окне — кнопка-булавка на плитке.
Закреплённый участник держит фокус, даже когда говорят другие; повторное
нажатие снимает закрепление, как и выход закреплённого из комнаты.
Закреплённая плитка помечена рамкой и подсвеченной булавкой; на
тач-устройствах булавка видна без наведения.
- Общий футер на всех страницах, кроме комнаты конференции: версия инстанса
и копирайт со ссылкой на автора.
### Изменения
- Основное окно конференции следует за активным говорящим, а не удерживает
выбранного один раз участника. Фокус не переключается на короткие реплики
(удержание 1.2 с) и не уводится с идущей демонстрации экрана. Среди
нескольких одновременно говорящих крупно показывается тот, у кого включена
камера. Мини-плеер (Document PiP) работает как раньше.
- Демонстрация экрана по-прежнему главнее всего: она перебивает и
закрепление, а после её завершения фокус возвращается на закреплённого
участника.
- Футер админки заменён общим компонентом; в брендовой панели экранов входа
строка копирайта с захардкоженным годом убрана — год теперь берётся
текущий.
### Исправлено
- Календарь: «г.» после года убрана и из подписей дней в самой сетке (видны
как всплывающая подсказка и в accessibility-дереве) — в недельном и
месячном виде. В 0.0.4 это было исправлено только для подписи периода.
## [0.0.5] — 2026-07-27
Контактный адрес инстанса: настраиваемый адрес для ответов на письма
(уходят от `no-reply@`) и кнопка проверки почтовой конфигурации из
админки.
### Добавлено
- Настройка «Контактный адрес» в админке (вкладка «Настройки») — email
подставляется в заголовок `Reply-To` писем подтверждения регистрации,
приглашений на конференции и саммари; при выключенной настройке
`Reply-To` не проставляется, поведение как раньше.
- Кнопка «Отправить тестовое письмо» — синхронная проверка почтовой
конфигурации без регистрации фиктивного пользователя. Получатель по
умолчанию — email текущего администратора, можно указать другой.
Результат виден сразу: успех либо текст ошибки транспорта (сервер
недоступен/получатель отклонён), без логина и пароля SMTP в ответе.
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
@@ -112,6 +245,12 @@
Первоначальная версия VidConf.
[0.0.10]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.10
[0.0.9]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.9
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2

View File

@@ -1 +1 @@
0.0.4
0.0.10

View File

@@ -13,7 +13,7 @@
import uuid
from pathlib import Path
from typing import Annotated
from typing import Annotated, Literal
import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -45,6 +45,8 @@ from schemas.admin import (
TeamListOut,
TeamOut,
TeamUpdateIn,
TestEmailIn,
TestEmailOut,
)
from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels
@@ -56,9 +58,11 @@ from services.conferences import (
InvalidConferenceStateError,
NotConferenceOwnerError,
)
from services.email import EmailSendError, create_email_backend
from services.instance_settings import (
InstanceSettingsService,
InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
@@ -170,12 +174,16 @@ async def send_conference_invitations(
async def list_users(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
media_root = _media_root()
items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
@@ -391,12 +399,59 @@ async def update_settings(
service = InstanceSettingsService(session)
try:
cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc:
except (
InvalidAiLevelError,
InvalidTimezoneError,
InvalidEmailDomainError,
InvalidContactEmailError,
) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.post("/settings/test-email", response_model=TestEmailOut)
async def send_test_email(
data: TestEmailIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TestEmailOut:
"""Отправить тестовое письмо синхронно — проверка почтовой конфигурации без
регистрации фиктивного пользователя.
Получатель по умолчанию — email текущего администратора. Отправка идёт
напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу.
Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт,
и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`).
"""
app_settings = get_app_settings()
recipient = data.to or admin.email
cfg = await InstanceSettingsService(session).get()
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
is_smtp = app_settings.email_backend == "smtp"
smtp_host = app_settings.smtp_host if is_smtp else None
smtp_port = app_settings.smtp_port if is_smtp else None
backend = create_email_backend(app_settings)
try:
await backend.send(
to=recipient,
subject="Тестовое письмо VidConf",
body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.",
reply_to=reply_to,
)
except EmailSendError as exc:
return TestEmailOut(
success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port
)
return TestEmailOut(
success=True,
message=f"Письмо успешно отправлено на {recipient}",
smtp_host=smtp_host,
smtp_port=smtp_port,
)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut(
@@ -409,7 +464,9 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email,
)

View File

@@ -53,17 +53,17 @@ async def registration_options(
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
включённой настройке `registration_email_domain`, иначе пустой список.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
)

View File

@@ -1,15 +1,17 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей.
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
сети, а не через публичный `/api/`-гейтвей.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо
реализации синхронного `prometheus_client.registry.Collector`.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
`vidconf_host_info` намеренно НЕ обновляются фоновой задачей — значения
пересчитываются прямо в обработчике запроса при каждом scrape (см.
докстринг `metrics_endpoint`), поэтому их асинхронные источники (БД,
Redis) можно опросить обычным `await` вместо реализации синхронного
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
и вовсе синхронный — настройки уже в памяти процесса).
"""
import time
@@ -20,6 +22,7 @@ from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_la
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.config import get_settings
from core.db import get_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
@@ -110,6 +113,42 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
HOST_INFO = Gauge(
"vidconf_host_info",
"Обнаруженное установщиком железо (info-метрика, значение всегда 1, данные в лейблах)",
labelnames=("cpus", "ram_mb", "gpu_name", "vram_mb"),
)
def _hw_label(value: int | None) -> str:
"""`None` (install.sh не запускался либо GPU не обнаружен) → «—», не пустая строка.
Пустой лейбл Grafana отрисовала бы как пустой текст на панели —
прочерк однозначно читается как «не определено».
"""
return str(value) if value is not None else ""
def _refresh_host_info_gauge() -> None:
"""Пересчитать `vidconf_host_info` по текущим `HW_*` настройкам (`core/config.py`).
Источник — `install.sh`, который пишет `HW_CPUS`/`HW_RAM_MB`/
`HW_GPU_NAME`/`HW_VRAM_MB` в `.env` при установке (ADR-004). Живые
CPU/RAM/диск хоста в дашборде «Хост и контейнеры» берутся из
node-exporter напрямую — здесь только то, чего node-exporter не
знает (GPU), плюс дублирование CPU/RAM install.sh для сверки.
"""
settings = get_settings()
HOST_INFO.labels(
cpus=_hw_label(settings.hw_cpus),
ram_mb=_hw_label(settings.hw_ram_mb),
gpu_name=settings.hw_gpu_name or "",
vram_mb=_hw_label(settings.hw_vram_mb),
).set(1)
@router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition.
@@ -122,4 +161,5 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
"""
await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge()
_refresh_host_info_gauge()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)

View File

@@ -77,8 +77,13 @@ class InstanceConfig(BaseModel):
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `POST /auth/register` принимает только email с доменом из
# `registration_email_domains` (любым из списка) — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False
contact_email: str | None = None

View File

@@ -2,7 +2,7 @@
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени."""
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, q: str | None, limit: int, offset: int
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
админки, у пользователя без команды — `None`.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
без фильтра (все пользователи). `q` ищет по email/имени пользователя
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
`INNER`, — пользователи без команды не должны пропадать из общей
выдачи, только не совпадать с поиском по названию команды).
"""
filters = []
filters: list[ColumnElement[bool]] = []
if status == "active":
filters.append(User.is_blocked.is_(False))
elif status == "blocked":
filters.append(User.is_blocked.is_(True))
if q:
like = f"%{q}%"
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
filters.append(
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
)
count_stmt = select(func.count()).select_from(User)
count_stmt = (
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
)
items_stmt = (
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)

View File

@@ -155,4 +155,29 @@ class SettingsOut(BaseModel):
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool
contact_email: str | None = None
class TestEmailIn(BaseModel):
"""Тело запроса тестовой отправки (`POST /admin/settings/test-email`).
`to` не задан — получатель по умолчанию: email текущего администратора
(см. `api/admin.py::send_test_email`).
"""
to: EmailStr | None = None
class TestEmailOut(BaseModel):
"""Результат тестовой отправки — понятный админу успех/ошибка транспорта.
`smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не
покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`.
"""
success: bool
message: str
smtp_host: str | None = None
smtp_port: int | None = None

View File

@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
`email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
пустой список.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None
email_domains: list[str] = Field(default_factory=list)

View File

@@ -106,9 +106,9 @@ class AuthService:
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
@@ -118,7 +118,7 @@ class AuthService:
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
if email_domain not in cfg.registration_email_domains:
raise InvalidEmailDomainError(email)
if team_id is not None:
@@ -134,7 +134,8 @@ class AuthService:
password_hash=hash_password(password),
team_id=team_id,
)
await self._issue_verification_email(user)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)
await self._session.commit()
return user
@@ -216,7 +217,7 @@ class AuthService:
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None:
async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours
@@ -231,6 +232,7 @@ class AuthService:
to=user.email,
subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}",
reply_to=reply_to,
)

View File

@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива)."""
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).
`reply_to` — необязательный контактный адрес инстанса (см.
`services/instance_settings.py::InstanceConfig.contact_email`), проставляется
заголовком `Reply-To`, если задан.
"""
...
@@ -72,13 +78,15 @@ class ConsoleEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s",
"EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
to,
subject,
reply_to or "нет",
attachment_names,
body,
)
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message(
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
body=body,
html_body=html_body,
attachments=attachments,
reply_to=reply_to,
)
try:
await aiosmtplib.send(
@@ -165,12 +175,15 @@ def _build_message(
body: str,
html_body: str | None,
attachments: Sequence[EmailAttachment],
reply_to: str | None = None,
) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage()
message["From"] = sender
message["To"] = to
message["Subject"] = subject
if reply_to:
message["Reply-To"] = reply_to
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")

View File

@@ -2,8 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
@@ -17,7 +17,8 @@ from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
@@ -61,7 +63,13 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -70,6 +78,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)
# Валидация формата контактного email — переиспользует тот же валидатор,
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
# отдельного регэкспа под адрес целиком.
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)
class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
@@ -87,7 +100,9 @@ class SettingsUpdateIn(BaseModel):
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
class BootstrapOverrides(BaseModel):
@@ -136,6 +151,7 @@ def build_bootstrap_defaults(
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
}
if overrides is None:
return defaults
@@ -172,6 +188,15 @@ class InvalidEmailDomainError(ValueError):
"""
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
Поднимается при попытке включить контактный адрес без email
(`enabled=true` и пустой/отсутствующий email) либо при email, не
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
"""
class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
@@ -217,7 +242,20 @@ class InstanceSettingsService:
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None:
if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
# Валидация только при фактической смене уровня (сравнение с уже
# сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
# ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
# блокировал бы сохранение несвязанных настроек на слабом железе,
# где текущий (давно и легитимно сохранённый) уровень недоступен
# по факту заново переоценённых требований (RAM/модели).
#
# Не ослабляем проверку и при отключённых transcriber.enabled/
# summarizer.enabled (когда уровень AI сейчас ни на что не
# влияет): если проверять по факту переключения — это осознанное
# намерение администратора сменить уровень, и молчаливое
# сохранение недоступного значения подставит администратора при
# последующем включении AI неработающей конфигурацией.
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
@@ -247,26 +285,50 @@ class InstanceSettingsService:
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
or patch.registration_email_domains is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
raw_domains = (
patch.registration_email_domains
if patch.registration_email_domains is not None
else cfg.registration_email_domains
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
domains = _normalize_email_domains(raw_domains)
if enabled and not domains:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
"нельзя включить верификацию домена email без указания хотя бы одного домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
cfg.registration_email_domains = domains
await self._set(
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
)
if patch.contact_email_enabled is not None or patch.contact_email is not None:
contact_enabled = (
patch.contact_email_enabled
if patch.contact_email_enabled is not None
else cfg.contact_email_enabled
)
raw_contact_email = (
patch.contact_email if patch.contact_email is not None else cfg.contact_email
)
contact_email = (
_normalize_contact_email(raw_contact_email) if raw_contact_email else None
)
if contact_enabled and contact_email is None:
raise InvalidContactEmailError(
"нельзя включить контактный адрес без указания email"
)
cfg.contact_email_enabled = contact_enabled
cfg.contact_email = contact_email
await self._set(
_KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
)
if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy(
@@ -360,6 +422,42 @@ def _normalize_email_domain(domain: str) -> str:
return normalized
def _normalize_email_domains(domains: list[str]) -> list[str]:
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
(с сохранением порядка первого вхождения)."""
normalized: list[str] = []
for raw in domains:
if not raw.strip():
continue
domain = _normalize_email_domain(raw)
if domain not in normalized:
normalized.append(domain)
return normalized
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
"""Достать список доменов из значения ключа `registration_email_domain`,
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
поддержки (`domain: str | None`, один домен) — на проде уже записано
именно старое значение, миграция БД для этого не нужна: следующий же
`update()` перепишет строку в новую форму (см. docstring `update`)."""
if "domains" in value:
return list(value["domains"])
legacy_domain = value.get("domain")
return [legacy_domain] if legacy_domain else []
def _normalize_contact_email(email: str) -> str:
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
normalized = email.strip().lower()
try:
_CONTACT_EMAIL_ADAPTER.validate_python(normalized)
except PydanticValidationError as exc:
raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
@@ -384,7 +482,11 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
registration_email_domains=_extract_email_domains(
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"enabled", False
),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
)

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User
from services import ai_levels
from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
@@ -379,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
assert all("email_verified" in item for item in items)
async def test_list_users_filters_by_status(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
admin = await _make_user(db_session, role="admin")
active_user = await _make_user(db_session)
blocked_user = await _make_user(db_session)
blocked_user.is_blocked = True
await db_session.commit()
active_response = await client.get(
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
)
assert active_response.status_code == 200, active_response.text
active_ids = [item["id"] for item in active_response.json()["items"]]
assert str(active_user.id) in active_ids
assert str(blocked_user.id) not in active_ids
blocked_response = await client.get(
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
)
assert blocked_response.status_code == 200, blocked_response.text
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
assert str(blocked_user.id) in blocked_ids
assert str(active_user.id) not in blocked_ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(active_user.id) in all_ids
assert str(blocked_user.id) in all_ids
async def test_list_users_search_matches_team_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`q` находит пользователей по (части) названия их команды; пользователи
без команды при этом не пропадают из общей (без поиска) выдачи."""
admin = await _make_user(db_session, role="admin")
suffix = uuid.uuid4().hex[:8]
team = Team(name=f"Rocket-{suffix} Squad")
db_session.add(team)
await db_session.flush()
teamed_user = await _make_user(db_session)
teamed_user.team_id = team.id
teamless_user = await _make_user(db_session)
await db_session.commit()
response = await client.get(
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
ids = [item["id"] for item in response.json()["items"]]
assert str(teamed_user.id) in ids
assert str(teamless_user.id) not in ids
assert str(admin.id) not in ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(teamless_user.id) in all_ids
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
@@ -606,3 +669,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
headers=_auth_headers(admin),
)
assert response.status_code == 400
async def test_put_settings_contact_email_enable_and_persist(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["contact_email_enabled"] is True
assert body["contact_email"] == "contact@vidconf.ru"
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
async def test_put_settings_contact_email_invalid_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "not an email"},
headers=_auth_headers(admin),
)
assert response.status_code == 400
# --- Тестовое письмо ----------------------------------------------------------------
async def test_send_test_email_defaults_to_admin_email(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is True
assert sent["to"] == admin.email
async def test_send_test_email_to_explicit_recipient(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email",
json={"to": "other@example.com"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert sent["to"] == "other@example.com"
async def test_send_test_email_uses_contact_email_as_reply_to(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
headers=_auth_headers(admin),
)
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert sent["reply_to"] == "contact@vidconf.example"
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
get_settings.cache_clear()
class _FailingBackend:
async def send(self, **kwargs: object) -> None:
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is False
assert "сбой SMTP" in body["message"]
assert body["smtp_host"] == "smtp.example.com"
assert body["smtp_port"] == 2525
assert "secret-user" not in response.text
assert "super-secret-password" not in response.text
finally:
get_settings.cache_clear()

View File

@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send(
self,
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
body: str,
html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None:
self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str:
@@ -235,7 +238,7 @@ async def test_registration_options_disabled_by_default(
body = response.json()
assert body["team_choice_enabled"] is False
assert body["teams"] == []
assert body["email_domain"] is None
assert body["email_domains"] == []
async def test_registration_options_enabled_returns_teams_sorted_by_name(
@@ -340,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
# --- Верификация регистрирующихся по домену email -------------------------------------
async def test_registration_options_returns_email_domain_when_enabled(
async def test_registration_options_returns_email_domains_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] == "example.com"
assert response.json()["email_domains"] == ["example.com", "corp.example"]
async def test_registration_options_email_domain_null_when_disabled(
async def test_registration_options_email_domains_empty_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await InstanceSettingsService(db_session).update(
@@ -372,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] is None
assert response.json()["email_domains"] == []
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
@@ -380,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
@@ -402,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await db_session.commit()
@@ -418,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
assert response.status_code == 201, response.text
async def test_register_with_second_domain_in_list_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "dave@corp.example",
"name_user": "Dave",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
@@ -432,3 +460,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
},
)
assert response.status_code == 201, response.text
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides,
InstanceSettingsService,
InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
SettingsUpdateIn,
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
)
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -131,7 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -321,7 +326,7 @@ async def test_registration_team_choice_toggle(
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
@@ -330,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_enable_with_empty_list_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Пустой список доменов при включении верификации — та же ошибка, что и
отсутствие поля (не молчаливое отключение проверки)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
)
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
async def test_registration_email_domain_rejects_invalid_pattern(
@@ -340,48 +362,168 @@ async def test_registration_email_domain_rejects_invalid_pattern(
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
cfg = await service.get()
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
отбрасываются, дубликаты схлопываются."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
registration_email_domain_enabled=True,
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
)
)
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domain == "corp.ru"
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
reloaded = await service.get()
assert reloaded.registration_email_domain == "corp.ru"
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="acme.io"
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
)
)
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain == "acme.io"
assert cfg.registration_email_domains == ["acme.io"]
async def test_registration_email_domain_reads_legacy_single_domain_shape(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
домен, без миграции на проде) — чтение должно разворачивать её в список из
одного элемента (см. `_extract_email_domains`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
cfg = await service.get()
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domains == ["legacy.example"]
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Первое же сохранение после чтения старой формы переписывает строку в
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
row = await db_session.get(InstanceSetting, "registration_email_domain")
assert row is not None
assert row.value == {"enabled": True, "domains": ["new.example"]}
async def test_contact_email_enable_without_email_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email_enabled=True))
cfg = await service.get()
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_contact_email_rejects_invalid_format(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email="not an email"))
cfg = await service.get()
assert cfg.contact_email is None
async def test_contact_email_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
)
assert cfg.contact_email_enabled is True
assert cfg.contact_email == "contact@vidconf.ru"
reloaded = await service.get()
assert reloaded.contact_email == "contact@vidconf.ru"
async def test_contact_email_can_be_disabled_keeping_stored_email(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
)
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
assert cfg.contact_email_enabled is False
assert cfg.contact_email == "contact@acme.io"
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
@@ -411,6 +553,52 @@ async def test_update_rejects_unavailable_ai_level(
assert cfg.ai_level == "min"
async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой
правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый
уровень стал недоступен (слабое железо/модель не докачана), это не
должно блокировать сохранение несвязанной настройки. Валидация нужна
только при фактической смене уровня (см. docstring `update`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(key="ai_level", value={"level": "medium"})
.on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}})
)
await db_session.execute(stmt)
await db_session.commit()
assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен
# ai_level в патче совпадает с уже сохранённым — не должно проверяться.
cfg = await service.update(
SettingsUpdateIn(ai_level="medium", registration_team_choice=True)
)
assert cfg.ai_level == "medium"
assert cfg.registration_team_choice is True
async def test_update_still_rejects_switching_to_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему
отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе
администратор молча сохранит нерабочую конфигурацию, которая проявится
только при последующем включении AI."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(SettingsUpdateIn(transcription_enabled=False))
with pytest.raises(InvalidAiLevelError):
await service.update(SettingsUpdateIn(ai_level="medium"))
cfg = await service.get()
assert cfg.ai_level == "min"
async def test_update_rejects_invalid_timezone(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text
from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments})
self.sent.append(
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
)
class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to
def _cfg(
*, contact_email_enabled: bool = False, contact_email: str | None = None
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
async def test_invitations_set_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(
_FakeTask(),
fx.conference_id,
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
)
assert mock_backend.sent
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
async def test_invitations_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
assert mock_backend.sent
assert all(call["reply_to"] is None for call in mock_backend.sent)
async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -20,6 +20,7 @@ from prometheus_client.samples import Sample
from sqlalchemy.ext.asyncio import AsyncSession
from api import metrics as metrics_module
from core.config import get_settings
from core.redis import redis_client
from models.conference import Conference
from models.session import ConferenceSession
@@ -57,6 +58,31 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
assert "vidconf_http_request_duration_seconds" in families
assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families
assert "vidconf_host_info" in families
async def test_metrics_host_info_gauge_reflects_settings(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`vidconf_host_info` отдаёт `HW_*` из настроек как лейблы, значение всегда 1."""
settings = get_settings()
monkeypatch.setattr(settings, "hw_cpus", 4)
monkeypatch.setattr(settings, "hw_ram_mb", 7937)
monkeypatch.setattr(settings, "hw_gpu_name", None)
monkeypatch.setattr(settings, "hw_vram_mb", None)
response = await client.get("/metrics")
samples = _samples(response.text, "vidconf_host_info")
value = _sample_value(
samples,
suffix="vidconf_host_info",
cpus="4",
ram_mb="7937",
gpu_name="",
vram_mb="",
)
assert value == 1
async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None:

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set()
self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str,
html_body: str | None = None,
attachments: Any = (),
reply_to: str | None = None,
) -> None:
if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig:
def _cfg(
*,
summary_recipients: str = "all",
contact_email_enabled: bool = False,
contact_email: str | None = None,
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(),
fx.session_id,
plugins_config=_cfg(
summary_recipients="owner",
contact_email_enabled=True,
contact_email="contact@vidconf.example",
),
)
assert backend.reply_to == ["contact@vidconf.example"]
async def test_notify_session_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.reply_to == [None]
async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -7,6 +7,8 @@
"""
import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib
import pytest
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def test_build_message_sets_reply_to_when_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
reply_to="contact@vidconf.example",
)
assert message["Reply-To"] == "contact@vidconf.example"
def test_build_message_omits_reply_to_when_not_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
)
assert message["Reply-To"] is None
def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend(
hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False,
"timeout": 30,
}
async def test_smtp_backend_send_passes_reply_to_into_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
)
message = cast(EmailMessage, captured["message"])
assert message["Reply-To"] == "contact@vidconf.example"

View File

@@ -82,7 +82,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.4}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.10}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно.
volumes:
@@ -704,6 +704,91 @@ services:
profiles: ["monitoring"]
logging: *default-logging
node-exporter:
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
# чего нет ни в одном из приложенческих экспортеров выше. Без
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
image: prom/node-exporter:v1.8.2
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
# ports) — Prometheus ходит к нему по внутренней сети compose
# (`node-exporter:9100`), публикация на хост для этого не нужна.
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
# Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
# cAdvisor, который на сервере `1gb` несовместим с Docker Engine
# (containerd-снапшоттер вместо classic overlay2 — подробности и
# перепробованные варианты см. `.forcc/JOURNAL.md`, раздел релиза 0.0.9).
# Вместо интроспекции graph-driver'а — Docker Engine API напрямую
# (`GET /containers/json` + `/stats`), он не зависит от storage-driver.
#
# Доступ к докер-сокету равносилен root на хосте (`:ro` при монтировании
# ограничивает права на файл-ноду, а не на протокол — через сокет можно
# поднять привилегированный контейнер и получить хост). Поэтому сокет
# монтируется СЮДА, в доверенный прокси-образ, а НЕ в наш собственный
# `container-exporter` ниже — прокси разрешает ему только
# `GET /containers/json` и `GET /containers/*/stats` (`CONTAINERS=1`),
# любые изменяющие запросы заблокированы (`POST=0`). Полная компрометация
# `container-exporter` не даёт управлять Docker. Обоснование и разбор —
# `docs/deploy/monitoring.md`.
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:v0.5.0
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1
# POST=0 — дефолт образа; выписан явно, потому что от него зависит
# безопасность всей схемы (полагаться на невыписанный дефолт для
# критичного флага не стоит).
POST: 0
EVENTS: 0
# Не публикуем порт наружу вообще — container-exporter достаёт API
# по имени сервиса во внутренней сети compose, тот же принцип, что и
# у node-exporter/postgres-exporter выше.
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:2375/_ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
container-exporter:
build:
context: ./monitoring/container-exporter
dockerfile: Dockerfile
restart: unless-stopped
environment:
DOCKER_API_BASE: "http://docker-socket-proxy:2375"
depends_on:
docker-socket-proxy:
condition: service_healthy
# Без ports — тот же принцип, что и у прокси выше; healthcheck
# прописан в самом Dockerfile экспортера (как у backend/worker).
profiles: ["monitoring"]
logging: *default-logging
grafana:
image: grafana/grafana:13.1.0
restart: unless-stopped

View File

@@ -60,3 +60,59 @@ groups:
# суммаризация встанет (задачи будут копиться в очереди summarize,
# см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
# Железо хоста (job `node` — node-exporter). Пороги подобраны под
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
# сменится, пересчитать.
- name: vidconf-host
rules:
# MemAvailable — уже честная оценка Linux с учётом того, что легко
# освобождаемый page cache/buffers не в счёт (в отличие от naive
# used = total - free). 10% от 8 ГБ ≈ 800 МБ — `for: 10m`, чтобы не
# дёргать на кратковременный всплеск (например, разовый всплеск
# transcribe/summarize), но успеть среагировать до OOM killer.
- alert: HostMemoryLow
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 10
for: 10m
labels:
severity: warning
annotations:
summary: "Мало свободной памяти на хосте"
description: >-
Свободно {{ $value | printf "%.1f" }}% RAM дольше 10 минут
(порог 10% ≈ 800 МБ из 8 ГБ). Смотреть, какой контейнер ест
память — дашборд «Хост и контейнеры», топ по памяти
(container-exporter, см. deploy/monitoring/container-exporter/).
# 50 ГБ диска — 10% ≈ 5 ГБ. `for: 15m` (не мгновенно): диск не растёт
# так же резко, как память, ложные срабатывания на всплеск не грозят,
# но и разовая проверка на границе некритична — 15 минут отсекает шум.
- alert: HostDiskLow
expr: (node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 < 10
for: 15m
labels:
severity: warning
annotations:
summary: "Мало места на диске хоста"
description: >-
Свободно {{ $value | printf "%.1f" }}% диска дольше 15 минут
(порог 10% ≈ 5 ГБ из 50 ГБ). Частые причины на этом проекте —
записи транскрибации (`recordings`), логи docker, образы/слои
после пересборки — проверить `docker system df`.
# 4 CPU. Порог 90% и `for: 15m` — сознательно строже по времени, чем
# у памяти/диска: кратковременные пики от пайплайна пост-обработки
# (транскрибация/суммаризация) — это ожидаемая, не аварийная нагрузка,
# алерт должен ловить именно устойчивую перегрузку, а не обычный всплеск.
- alert: HostCpuHigh
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 15m
labels:
severity: warning
annotations:
summary: "Устойчиво высокая загрузка CPU хоста"
description: >-
Загрузка CPU {{ $value | printf "%.1f" }}% дольше 15 минут
подряд (порог 90% из 4 ядер). Смотреть топ контейнеров по CPU
(дашборд «Хост и контейнеры») и латентность API — возможно,
не хватает уровня AI/ресурсов под нагрузку.

View File

@@ -0,0 +1,32 @@
FROM python:3.12-slim AS base
# uv binary (тот же приём, что и backend/Dockerfile).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY exporter.py ./
# Непривилегированный пользователь — сокет Docker сервис не видит вообще
# (доступ только к socket-proxy по сети compose), но принцип наименьших
# привилегий соблюдаем и здесь.
RUN useradd --no-create-home --shell /usr/sbin/nologin exporter
USER exporter
EXPOSE 9419
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9419/health')" || exit 1
# Не `uv run` — тому нужен писабельный кэш в $HOME, а у непривилегированного
# пользователя без домашней директории (--no-create-home выше) его нет;
# зависимости уже установлены в .venv на шаге сборки, `uv run` в рантайме
# не добавляет ничего, кроме этой проблемы.
CMD [".venv/bin/python", "exporter.py"]

View File

@@ -0,0 +1,234 @@
"""Экспортер метрик Prometheus по контейнерам поверх Docker Engine API.
Отдельный минимальный сервис (не часть backend — backend не должен иметь
доступ к докер-сокету, см. `docs/deploy/monitoring.md`). Опрашивает Docker
Engine API ЧЕРЕЗ `docker-socket-proxy` (`deploy/docker-compose.yml`) —
самому экспортеру `/var/run/docker.sock` не примонтирован, только доступ
по внутренней сети compose к прокси, который разрешает исключительно
`GET /containers/json` и `GET /containers/{id}/stats` (только чтение, без
прав управлять Docker — см. обоснование в `docs/deploy/monitoring.md`).
Стек — `aiohttp` (не `httpx`/FastAPI, как у backend): это отдельный
процесс вне зависимостей backend, а `aiohttp` даёт async HTTP-клиент и
сервер в одной небольшой библиотеке, без лишних зависимостей ради пяти
строк веб-сервера.
Метрики (КОНТРАКТ с `deploy/monitoring/prometheus.yml` и
`deploy/monitoring/grafana/dashboards/host.json` — при переименовании
поправить оба файла), лейбл везде — `name` (имя контейнера):
- `vidconf_container_cpu_percent` — доля CPU, использованная контейнером
за последний интервал опроса, тот же расчёт, что у `docker stats`
(0-100 на одно ядро, суммарно может быть больше 100 при использовании
нескольких ядер).
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — из
`stats.memory_stats`, `usage` очищен от переиспользуемого page cache
(`inactive_file`) тем же приёмом, что cAdvisor использовал для
`container_memory_working_set_bytes` — именно это число cgroup
учитывает при OOM. Без явного лимита памяти у сервиса в
docker-compose `limit` равен общему объёму RAM хоста — это не баг.
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по всем
сетевым интерфейсам контейнера, счётчик со старта контейнера
(обнуляется при пересоздании) — для скорости передачи взять `rate()`.
Опрос контейнеров идёт ПАРАЛЛЕЛЬНО (`asyncio.gather`) в фоновой задаче со
своим интервалом (`POLL_INTERVAL_S`), а не синхронно на каждый scrape
Prometheus: `GET /containers/{id}/stats?stream=false` не мгновенен (Docker
считает CPU-дельту по двум внутренним замерам с разницей ~1с — проверено
на реальном стенде), и линейный опрос дюжины контейнеров легко вышел бы за
scrape-интервал Prometheus (15с). Параллельный опрос укладывается в ~1-2с
независимо от числа контейнеров (проверено на 3 контейнерах, 4 запроса на
каждый параллельно — 2с суммарно). Фоновая задача пишет значения прямо в
метрики `prometheus_client` (они и есть кэш), обработчик `/metrics` не
трогает Docker API вообще — стабильная латентность ответа независимо от
состояния Docker API.
"""
import asyncio
import logging
import os
import time
from typing import Any
import aiohttp
from aiohttp import web
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("container_exporter")
DOCKER_API_BASE = os.environ.get("DOCKER_API_BASE", "http://docker-socket-proxy:2375")
POLL_INTERVAL_S = float(os.environ.get("POLL_INTERVAL_S", "15"))
REQUEST_TIMEOUT_S = float(os.environ.get("REQUEST_TIMEOUT_S", "10"))
LISTEN_PORT = int(os.environ.get("PORT", "9419"))
CPU_PERCENT = Gauge(
"vidconf_container_cpu_percent",
"Использование CPU контейнером, % от одного ядра (как `docker stats`)",
labelnames=("name",),
)
MEMORY_USAGE_BYTES = Gauge(
"vidconf_container_memory_usage_bytes",
"Память контейнера без переиспользуемого page cache (working set)",
labelnames=("name",),
)
MEMORY_LIMIT_BYTES = Gauge(
"vidconf_container_memory_limit_bytes",
"Лимит памяти контейнера (= RAM хоста, если лимит не задан явно)",
labelnames=("name",),
)
NETWORK_RX_BYTES = Gauge(
"vidconf_container_network_rx_bytes",
"Байт получено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
labelnames=("name",),
)
NETWORK_TX_BYTES = Gauge(
"vidconf_container_network_tx_bytes",
"Байт отправлено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
labelnames=("name",),
)
class PollState:
"""Флаг успешности последнего опроса — источник `/health` для compose."""
last_poll_ok: bool = False
def _container_name(container: dict[str, Any]) -> str:
"""Имя контейнера без ведущего `/` (Docker API отдаёт список имён с ним)."""
names = container.get("Names") or []
if names:
return str(names[0]).lstrip("/")
return str(container["Id"])[:12]
def _cpu_percent(stats: dict[str, Any]) -> float | None:
"""Доля CPU за интервал между `precpu_stats` и `cpu_stats` — расчёт `docker stats` (CLI).
Оба поля Docker кладёт в один ответ `stats?stream=false` сам (снимает
дважды с разницей ~1с внутри демона) — считать дельту вручную между
двумя нашими запросами не нужно.
"""
cpu = stats.get("cpu_stats") or {}
precpu = stats.get("precpu_stats") or {}
cpu_total: int | None = (cpu.get("cpu_usage") or {}).get("total_usage")
precpu_total: int | None = (precpu.get("cpu_usage") or {}).get("total_usage")
system: int | None = cpu.get("system_cpu_usage")
presystem: int | None = precpu.get("system_cpu_usage")
# Первый снимок контейнера (только что создан) — precpu_stats пуст.
if cpu_total is None or precpu_total is None or system is None or presystem is None:
return None
cpu_delta = cpu_total - precpu_total
system_delta = system - presystem
if system_delta <= 0 or cpu_delta < 0:
return None
percpu_usage = (cpu.get("cpu_usage") or {}).get("percpu_usage") or []
online_cpus = cpu.get("online_cpus") or len(percpu_usage) or 1
return float((cpu_delta / system_delta) * online_cpus * 100.0)
def _apply_stats(name: str, stats: dict[str, Any]) -> None:
cpu_percent = _cpu_percent(stats)
if cpu_percent is not None:
CPU_PERCENT.labels(name=name).set(cpu_percent)
memory = stats.get("memory_stats") or {}
usage = memory.get("usage")
limit = memory.get("limit")
cache = (memory.get("stats") or {}).get("inactive_file", 0)
if usage is not None:
MEMORY_USAGE_BYTES.labels(name=name).set(max(usage - cache, 0))
if limit is not None:
MEMORY_LIMIT_BYTES.labels(name=name).set(limit)
networks: dict[str, Any] = stats.get("networks") or {}
if networks:
rx = sum(net.get("rx_bytes", 0) for net in networks.values())
tx = sum(net.get("tx_bytes", 0) for net in networks.values())
NETWORK_RX_BYTES.labels(name=name).set(rx)
NETWORK_TX_BYTES.labels(name=name).set(tx)
async def _fetch_and_apply(session: aiohttp.ClientSession, container: dict[str, Any]) -> None:
name = _container_name(container)
container_id = container["Id"]
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
try:
async with session.get(
f"{DOCKER_API_BASE}/containers/{container_id}/stats",
params={"stream": "false"},
timeout=timeout,
) as response:
response.raise_for_status()
stats = await response.json(content_type=None)
except Exception:
logger.warning("не удалось получить stats контейнера %s", name, exc_info=True)
return
_apply_stats(name, stats)
async def _poll_once(session: aiohttp.ClientSession) -> None:
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
async with session.get(f"{DOCKER_API_BASE}/containers/json", timeout=timeout) as response:
response.raise_for_status()
containers = await response.json(content_type=None)
# Параллельно — см. докстринг модуля про латентность stats-запроса.
await asyncio.gather(*(_fetch_and_apply(session, c) for c in containers))
async def _poll_loop(state: PollState) -> None:
async with aiohttp.ClientSession() as session:
while True:
start = time.monotonic()
try:
await _poll_once(session)
state.last_poll_ok = True
except Exception:
logger.exception("опрос Docker API завершился ошибкой")
state.last_poll_ok = False
elapsed = time.monotonic() - start
await asyncio.sleep(max(POLL_INTERVAL_S - elapsed, 1.0))
# `aiohttp.web.Response(content_type=...)` не принимает `charset` внутри
# строки типа (падает `ValueError`), а `CONTENT_TYPE_LATEST` из
# `prometheus_client` — "text/plain; version=0.0.4; charset=utf-8" целиком;
# разбираем один раз на media type (без `charset=...`) и сам charset.
_METRICS_MEDIA_TYPE = "; ".join(
part for part in CONTENT_TYPE_LATEST.split("; ") if not part.startswith("charset=")
)
async def metrics_handler(_request: web.Request) -> web.Response:
return web.Response(body=generate_latest(), content_type=_METRICS_MEDIA_TYPE, charset="utf-8")
def _make_health_handler(state: PollState) -> Any:
async def health_handler(_request: web.Request) -> web.Response:
if state.last_poll_ok:
return web.Response(text="ok")
return web.Response(text="опрос Docker API ещё не выполнялся успешно", status=503)
return health_handler
def create_app() -> web.Application:
app = web.Application()
state = PollState()
app.router.add_get("/metrics", metrics_handler)
app.router.add_get("/health", _make_health_handler(state))
async def on_startup(_app: web.Application) -> None:
app["poll_task"] = asyncio.create_task(_poll_loop(state))
async def on_cleanup(_app: web.Application) -> None:
app["poll_task"].cancel()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
return app
if __name__ == "__main__":
web.run_app(create_app(), host="0.0.0.0", port=LISTEN_PORT)

View File

@@ -0,0 +1,26 @@
[project]
name = "vidconf-container-exporter"
version = "0.1.0"
description = "Метрики Prometheus по контейнерам поверх Docker Engine API (профиль monitoring, deploy/docker-compose.yml)."
requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.10.0",
"prometheus-client>=0.25.0",
]
[dependency-groups]
dev = [
"mypy>=2.3.0",
"ruff>=0.15.21",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
[tool.mypy]
python_version = "3.12"
strict = true

View File

@@ -0,0 +1,747 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "aiohappyeyeballs"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
]
[[package]]
name = "aiohttp"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "ast-serialize"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
{ url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
{ url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
{ url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
{ url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
{ url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
{ url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
{ url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
{ url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
{ url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
{ url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
{ url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
{ url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
{ url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
{ url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "librt"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
{ url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" },
{ url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" },
{ url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" },
{ url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" },
{ url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" },
{ url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" },
{ url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" },
{ url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" },
{ url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" },
{ url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" },
{ url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" },
{ url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" },
{ url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" },
{ url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" },
{ url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" },
{ url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mypy"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" },
{ url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" },
{ url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" },
{ url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" },
{ url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" },
{ url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
{ url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
{ url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" },
{ url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" },
{ url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" },
{ url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" },
{ url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" },
{ url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" },
{ url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" },
]
[[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 = "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 = "prometheus-client"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
]
[[package]]
name = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
name = "ruff"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[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 = "vidconf-container-exporter"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "prometheus-client" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.10.0" },
{ name = "prometheus-client", specifier = ">=0.25.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=2.3.0" },
{ name = "ruff", specifier = ">=0.15.21" },
]
[[package]]
name = "yarl"
version = "1.24.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" },
{ url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" },
{ url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" },
{ url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" },
{ url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" },
{ url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" },
{ url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" },
{ url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" },
{ url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" },
{ url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
{ url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
{ url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
{ url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
{ url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
{ url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
{ url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
{ url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
{ url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
{ url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
{ url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
{ url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
{ url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
{ url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
{ url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
{ url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
{ url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
{ url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
{ url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
{ url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
{ url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
]

View File

@@ -0,0 +1,354 @@
{
"title": "Хост и контейнеры",
"description": "CPU/RAM/диск/сеть хоста (node-exporter), топ контейнеров по CPU/памяти (container-exporter поверх Docker Engine API — замена cAdvisor, несовместимого с containerd-снапшоттером сервера 1gb, см. .forcc/JOURNAL.md) и характеристики сервера — профиль compose monitoring. Пороги на панелях согласованы с deploy/monitoring/alerts.yml (группа vidconf-host).",
"uid": "vidconf-host",
"editable": false,
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"time": { "from": "now-6h", "to": "now" },
"refresh": "30s",
"tags": ["vidconf", "host"],
"panels": [
{
"id": 1,
"title": "Загрузка CPU",
"description": "100 - idle, усреднено по всем ядрам. Порог алерта HostCpuHigh — 90% дольше 15 минут, см. alerts.yml.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU busy",
"refId": "A"
}
]
},
{
"id": 2,
"title": "Load average",
"description": "node_load1/5/15 — на 4-ядерном сервере устойчивое значение выше ~4 означает очередь на CPU.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load1", "legendFormat": "1 мин", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load5", "legendFormat": "5 мин", "refId": "B" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load15", "legendFormat": "15 мин", "refId": "C" }
]
},
{
"id": 3,
"title": "Использование RAM",
"description": "(1 - MemAvailable/MemTotal) — MemAvailable уже учитывает легко освобождаемый page cache/buffers. Порог алерта HostMemoryLow — доступно <10% дольше 10 минут, см. alerts.yml.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "RAM used",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Своп (использовано)",
"description": "SwapTotal - SwapFree. На этом сервере swap не заведён под мониторинг отдельным алертом — рост от нуля сам по себе сигнал, что памяти уже не хватает (см. HostMemoryLow).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes",
"legendFormat": "swap used",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Свободно на диске (/)",
"description": "node_filesystem_avail_bytes для корня. Порог алерта HostDiskLow — <10% (≈5 ГБ из 50 ГБ) дольше 15 минут.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 5368709120 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
"refId": "A"
}
]
},
{
"id": 6,
"title": "RAM сейчас, %",
"description": "Текущий снимок панели «Использование RAM». Порог совпадает с HostMemoryLow (used > 90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"refId": "A"
}
]
},
{
"id": 7,
"title": "Диск сейчас, %",
"description": "Текущий снимок использования корневого раздела. Порог совпадает с HostDiskLow (used > 90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}) * 100",
"refId": "A"
}
]
},
{
"id": 8,
"title": "CPU сейчас, %",
"description": "Текущий снимок загрузки CPU. Порог совпадает с HostCpuHigh (>90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"refId": "A"
}
]
},
{
"id": 9,
"title": "Сетевой трафик",
"description": "rx/tx по физическим интерфейсам хоста (исключены lo/veth/docker/br — это внутренние интерфейсы контейнеров, а не реальный сетевой трафик сервера).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "Bps", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
"legendFormat": "rx {{device}}",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
"legendFormat": "tx {{device}}",
"refId": "B"
}
]
},
{
"id": 10,
"title": "Топ контейнеров по CPU",
"description": "Источник — container-exporter (job container-exporter, свой сервис поверх Docker Engine API, см. deploy/monitoring/container-exporter/). Отвечает на вопрос «кто из контейнеров грузит CPU» при срабатывании HostCpuHigh. % от одного ядра — как `docker stats`, суммарно может быть больше 100 при нескольких ядрах.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "topk(5, vidconf_container_cpu_percent)",
"legendFormat": "{{name}}",
"refId": "A"
}
]
},
{
"id": 11,
"title": "Топ контейнеров по памяти",
"description": "vidconf_container_memory_usage_bytes — usage без переиспользуемого page cache (working set), источник — container-exporter.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "topk(5, vidconf_container_memory_usage_bytes)",
"legendFormat": "{{name}}",
"refId": "A"
}
]
},
{
"id": 12,
"title": "Ядра CPU",
"description": "Число ядер хоста (node-exporter, живое значение — работает на любой инсталляции, не только на 1gb).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "count(count by (cpu) (node_cpu_seconds_total{mode=\"idle\"}))",
"refId": "A"
}
]
},
{
"id": 13,
"title": "Объём RAM",
"description": "Полный объём памяти хоста (node-exporter, живое значение).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_memory_MemTotal_bytes",
"refId": "A"
}
]
},
{
"id": 14,
"title": "Диск (/)",
"description": "Полный размер корневого раздела (node-exporter, живое значение) — не путать с панелью «Свободно на диске» выше (там остаток).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
"refId": "A"
}
]
},
{
"id": 15,
"title": "GPU",
"description": "Обнаруженная install.sh видеокарта (backend, gauge vidconf_host_info — HW_GPU_NAME из .env, ADR-004). «—» — видеокарты нет либо install.sh не запускался (dev-окружение). Таблица, а не stat-панель: у stat-панели в режиме отображения имени серии свой алгоритм автоподбора размера шрифта, который для однознаковых значений вроде «—» рисует нечитаемо мелкий текст.",
"type": "table",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "align": "center" } },
"overrides": []
},
"options": {
"showHeader": true,
"cellHeight": "lg"
},
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"cpus": true,
"ram_mb": true,
"instance": true,
"job": true
},
"renameByName": { "gpu_name": "Модель", "vram_mb": "VRAM, МБ" }
}
}
],
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "vidconf_host_info",
"format": "table",
"instant": true,
"refId": "A"
}
]
}
]
}

View File

@@ -1,6 +1,7 @@
# Конфигурация Prometheus (devops) — сбор метрик backend,
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
# PostgreSQL, Redis, железа хоста, контейнеров и локального LLM-сервера.
# Поднимается compose-профилем `monitoring` (deploy/docker-compose.yml,
# сервис `prometheus`).
#
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
@@ -33,6 +34,25 @@ scrape_configs:
static_configs:
- targets: ["redis-exporter:9121"]
# Хост целиком: CPU, память, диск, сеть, load average (профиль monitoring
# — сервис node-exporter). Единственный источник, который покажет
# нехватку памяти/CPU на сервере, если она не проявится как рост
# латентности API (см. дашборд host.json).
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
# Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend,
# worker, postgres и т.д. — профиль monitoring, сервис
# `container-exporter`). Отвечает на вопрос «какой из контейнеров ест
# ресурсы», в отличие от job `node` (только хост целиком). Замена
# cAdvisor (несовместим с containerd-снапшоттером сервера `1gb`, см.
# .forcc/JOURNAL.md) — свой экспортер поверх Docker Engine API,
# метрики `vidconf_container_*`, см. deploy/monitoring/container-exporter/.
- job_name: container-exporter
static_configs:
- targets: ["container-exporter:9419"]
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни

View File

@@ -10,11 +10,68 @@
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборд «Пайплайны пост-обработки» |
| `node-exporter` | `prom/node-exporter:v1.8.2` | — (внутренний) | метрики хоста: CPU, память, диск, сеть, load average |
| `docker-socket-proxy` | `tecnativa/docker-socket-proxy:v0.5.0` | — (внутренний) | read-only прокси к Docker Engine API для `container-exporter` |
| `container-exporter` | сборка из `deploy/monitoring/container-exporter/` | — (внутренний) | метрики CPU/память/сеть в разбивке по контейнерам |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» |
`node-exporter`/`docker-socket-proxy`/`container-exporter` не публикуют
порт на хост вообще (не только 127.0.0.1) — Prometheus и сам экспортер
ходят друг к другу по имени сервиса во внутренней сети compose,
публикация на хост для этого не нужна.
### 1.1. Метрики по контейнерам — свой экспортер вместо cAdvisor
**cAdvisor не используется**: Docker Engine на сервере `1gb` использует
containerd-снапшоттер (`docker info``driver-type:
io.containerd.snapshotter.v1`), а не классический overlay2-драйвер, и
cAdvisor (проверено на v0.49.2 и v0.52.1, с `--docker_only` и без, через
`--containerd`/`--containerd-namespace=moby` и без) не может определить
read-write layer контейнеров — падает с «failed to identify the
read-write layer ID», метрики есть только по корневому cgroup. Открытая
проблема совместимости cAdvisor с containerd image store, флагами не
чинится — см. `.forcc/JOURNAL.md`.
Вместо этого — свой минимальный экспортер (`container-exporter`,
`deploy/monitoring/container-exporter/exporter.py`, Python/aiohttp/
`prometheus_client`) поверх Docker Engine API: `GET /containers/json` +
`GET /containers/{id}/stats?stream=false`. Эти эндпойнты не зависят от
storage-driver'а — работают и с containerd-снапшоттером, и с overlay2.
Опрос контейнеров идёт параллельно в фоновой задаче (свой интервал,
не завязан на scrape Prometheus) — `stats?stream=false` не мгновенен
(Docker считает CPU-дельту по двум внутренним замерам с разницей ~1с),
линейный опрос дюжины контейнеров легко вышел бы за scrape-интервал.
Метрики (лейбл `name` — имя контейнера):
- `vidconf_container_cpu_percent` — % от одного ядра, как `docker stats`.
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — working set
(без переиспользуемого page cache) и лимит памяти.
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по
интерфейсам, счётчик со старта контейнера.
**Безопасность: доступ к `/var/run/docker.sock` равносилен root на
хосте.** Флаг `:ro` при монтировании сокета не спасает — он ограничивает
права на файл-ноду, а не на протокол: через сокет можно поднять
привилегированный контейнер и получить хост. Поэтому сокет НЕ
монтируется напрямую в `container-exporter` — между ними стоит
`docker-socket-proxy` (`tecnativa/docker-socket-proxy`), которому
разрешены только `GET`-запросы по контейнерам (`CONTAINERS=1`, `POST=0`
— дефолт образа, выписан явно). Даже полная компрометация
`container-exporter` (например, через уязвимость в его зависимостях) не
даёт управлять Docker — прокси физически не пропустит `POST`. Ни один
из двух сервисов не публикует портов наружу.
Если на другом сервере используется классический overlay2 и хочется
именно cAdvisor — можно добавить его по тому же образцу, что и
`node-exporter` (свой job в `prometheus.yml`, без публикации портов),
но тогда придётся решать ту же проблему монтирования сокета отдельно
(cAdvisor тоже требует доступ к Docker API).
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`.
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`,
`deploy/monitoring/grafana/dashboards/host.json`,
`deploy/monitoring/container-exporter/` (код экспортера).
## 2. Запуск
@@ -37,9 +94,9 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
```
и открывайте `http://localhost:9090` / `http://localhost:3001` у себя.
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется
сразу — источник данных и дашборд провижинятся из файлов, без ручной
настройки.
Дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» (папка VidConf
в Grafana) появляются сразу — источник данных и дашборды провижинятся из
файлов, без ручной настройки.
## 3. Метрики backend
@@ -56,6 +113,13 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
- `vidconf_celery_queue_depth` (gauge, `queue`) — глубина очередей Celery
(`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`), карта
очередей — `docs/deploy/scaling.md`.
- `vidconf_host_info` (gauge, всегда `1`, лейблы `cpus`/`ram_mb`/`gpu_name`/
`vram_mb`) — info-метрика обнаруженного `install.sh` железа (`HW_*` в
`.env`, ADR-004). Источник плашки с характеристиками сервера в дашборде
«Хост и контейнеры» — живые CPU/RAM/диск там же берутся из
node-exporter, а GPU node-exporter не знает вообще, поэтому только эта
метрика. `«—»` в лейбле — `install.sh` не запускался (dev) либо
видеокарта не обнаружена.
Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
(`LLAMA_ARG_ENDPOINT_METRICS=1`) — этот адрес резолвится ЛИБО сервисом
@@ -78,6 +142,13 @@ Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical (закомментирован по умолчанию) |
| `HostMemoryLow` | свободно <10% RAM (≈800 МБ из 8 ГБ) дольше 10 минут | warning |
| `HostDiskLow` | свободно <10% диска (≈5 ГБ из 50 ГБ) дольше 15 минут | warning |
| `HostCpuHigh` | загрузка CPU >90% дольше 15 минут подряд | warning |
Пороги трёх алертов по железу подобраны под конкретный сервер `1gb`
(8 ГБ RAM, 4 CPU, 50 ГБ диска) — при смене сервера пересчитать
(`deploy/monitoring/alerts.yml`, группа `vidconf-host`).
`LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
(пресеты 35) — по умолчанию (профили `media,monitoring`, без AI) правило

View File

@@ -32,8 +32,13 @@ export interface SettingsOut {
registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null
/** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
* совпадает с любым из списка; пуст, если верификация выключена. */
registration_email_domains: string[]
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -45,9 +50,27 @@ export interface SettingsUpdateIn {
summary_recipients?: SummaryRecipientsMode
display_timezone?: string
registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */
/** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
registration_email_domain_enabled?: boolean
registration_email_domain?: string | null
registration_email_domains?: string[]
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
export interface TestEmailIn {
/** Не задан — backend отправит на email текущего администратора. */
to?: string | null
}
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
export interface TestEmailOut {
success: boolean
message: string
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
smtp_host: string | null
smtp_port: number | null
}
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
@@ -114,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */
export interface AdminUserQuery {
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
status?: 'active' | 'blocked'
q?: string
limit?: number
offset?: number
@@ -198,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
/** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
}
@@ -238,6 +263,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
}
/**
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
*/
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
}
/** Список команд для админки — с пагинацией. */
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset })

View File

@@ -21,8 +21,8 @@ export interface RegistrationTeamOption {
export interface RegistrationOptions {
team_choice_enabled: boolean
teams: RegistrationTeamOption[]
/** Эталонный домен почты при включённой верификации, иначе `null`. */
email_domain: string | null
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
}
export interface CurrentUser {

View File

@@ -18,10 +18,10 @@ import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
{ value: 'all', label: 'Все' },
]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
@@ -287,7 +287,7 @@ function InviteModal({ conference, onClose }: { conference: AdminConferenceOut;
* блок «table-card», адаптированный под сущность «конференция»).
*/
export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('scheduled')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)

View File

@@ -1,9 +1,18 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
import { AlertTriangle, X } from 'lucide-react'
import {
getAdminSettings,
sendTestEmail,
updateAdminSettings,
type AiLevel,
type SettingsOut,
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient()
const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
@@ -50,7 +60,12 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
@@ -67,17 +82,61 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
})
const testEmailMutation = useMutation({
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
onSuccess: (result) => setTestEmailResult(result),
onError: (err: unknown) => {
setTestEmailResult({
success: false,
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
smtp_host: null,
smtp_port: null,
})
},
})
function addDomain() {
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
if (!domain || emailDomains.includes(domain)) {
setNewDomainInput('')
return
}
setEmailDomains([...emailDomains, domain])
setNewDomainInput('')
}
function removeDomain(domain: string) {
setEmailDomains(emailDomains.filter((d) => d !== domain))
}
function handleSave() {
mutation.mutate({
chat_enabled: chatEnabled,
transcription_enabled: aiEnabled,
ai_level: aiLevel,
summary_recipients: recipients,
display_timezone: timezone.trim(),
registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null,
})
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
// опциональных полей именно для этого): иначе, например, ai_level
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
// валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
if (teamChoiceEnabled !== data.registration_team_choice) {
payload.registration_team_choice = teamChoiceEnabled
}
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
payload.registration_email_domain_enabled = domainVerificationEnabled
}
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
payload.registration_email_domains = emailDomains
}
if (contactEmailEnabled !== data.contact_email_enabled) {
payload.contact_email_enabled = contactEmailEnabled
}
const trimmedContactEmail = contactEmail.trim() || null
if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail
}
mutation.mutate(payload)
}
return (
@@ -200,19 +259,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<div className="settings-card-body">
{emailDomains.length > 0 && (
<div className="participants-chips">
{emailDomains.map((domain) => (
<span className="participant-chip" key={domain}>
{domain}
<button
type="button"
className="participant-chip-remove"
aria-label={`Убрать домен: ${domain}`}
onClick={() => removeDomain(domain)}
disabled={!domainVerificationEnabled}
>
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={newDomainInput}
disabled={!domainVerificationEnabled}
onChange={(e) => setNewDomainInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addDomain()
}
}}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
onClick={addDomain}
>
Добавить
</button>
</div>
</div>
</div>
</section>
@@ -268,6 +361,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div>
</section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">
Адрес для ответов на письма от инстанса (уходят от no-reply этот адрес подставляется
в заголовок «Reply-To»).
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Контактный адрес включён</strong>
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={contactEmailEnabled}
onChange={(e) => setContactEmailEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-contact-email">Email</label>
<input
id="settings-contact-email"
type="email"
placeholder="contact@vidconf.ru"
value={contactEmail}
disabled={!contactEmailEnabled}
onChange={(e) => setContactEmail(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
<div className="settings-card-body">
<div className="field">
<label htmlFor="settings-test-email-to">Получатель</label>
<input
id="settings-test-email-to"
type="email"
placeholder={user?.email ?? 'you@example.com'}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={testEmailMutation.isPending}
onClick={() => testEmailMutation.mutate()}
>
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
</button>
{testEmailResult && (
<p
className="field-hint"
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
>
{testEmailResult.message}
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
</p>
)}
</div>
</section>
<div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}

View File

@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
{ value: 'active', label: 'Активные' },
{ value: 'blocked', label: 'Заблокированные' },
{ value: 'all', label: 'Все' },
]
/**
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
* роли, блокировкой и командой (design/mockups/admin.html,
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
*/
export function AdminUsersTab() {
const { user: currentUser } = useAuth()
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('active')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, offset],
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
queryKey: ['admin', 'users', statusFilter, search, offset],
queryFn: () =>
listAdminUsers({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const { data: teamsData } = useQuery({
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
return (
<>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row">
<div className="toolbar-left">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по имени или email…"
placeholder="Поиск по имени, email или команде…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/auth.css'
@@ -40,12 +41,16 @@ export function AuthLayout({ children }: { children: ReactNode }) {
</div>
</div>
</div>
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
© 2026 VidConf · self-hosted
</p>
</aside>
<main className="form-panel">{children}</main>
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
остальных страницах). Строку «© 2026 VidConf · self-hosted» из
брендовой панели при этом убрали совсем (решение оператора): год в ней
был захардкожен, а копирайт теперь живёт в футере. */}
<main className="form-panel">
{children}
<AppFooter />
</main>
</div>
)
}

View File

@@ -98,6 +98,57 @@ function renderEventContent(arg: EventContentArg) {
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Подписи дней в самой сетке, из которых надо убрать «г.» (см.
* `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
* подпись колонки дня.
*/
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'
/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr)
if (!value) continue
const cleaned = value.replace(YEAR_SUFFIX_RE, '')
if (cleaned !== value) el.setAttribute(attr, cleaned)
}
}
/**
* Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
*
* Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
* дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
* accessibility-дереве и как всплывающая подсказка. Формат этой подписи
* библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
* форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
* результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
* `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
* поэтому правим уже отрендеренный атрибут.
*
* Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
* при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
* уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
* подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
* живой проверке: после переключения вида ровно строка переиспользованных
* ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
* Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
* реально меняется, поэтому вызванная нашей же правкой итерация ничего не
* пишет и цепочка обрывается.
*/
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = rootRef.current
if (!root) return
const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
clean()
const observer = new MutationObserver(clean)
observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
return () => observer.disconnect()
}, [rootRef])
}
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
@@ -152,6 +203,9 @@ export function ConferenceCalendar({
}: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null)
const gridRef = useRef<HTMLDivElement | null>(null)
useStripYearSuffix(gridRef)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
@@ -269,7 +323,7 @@ export function ConferenceCalendar({
</div>
</div>
<div className="vc-calendar" aria-busy={isLoading}>
<div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
<FullCalendar
ref={calendarRef}
plugins={CALENDAR_PLUGINS}

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { getHealth } from '@/api/health'
import '@/styles/footer.css'
/**
* Общий футер приложения: версия инстанса и копирайт со ссылкой на автора.
*
* Ставится на всех страницах, КРОМЕ комнаты конференции (`RoomPage`) — там
* полноэкранное видео и тулбар, футеру места нет. Раскладок в приложении
* несколько (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`), поэтому
* компонент рендерит только собственный `<footer>`, а привязку к конкретной
* раскладке делает CSS (см. `styles/footer.css`).
*
* Версия — из публичного `GET /api/health` (авторизации не требует, годится и
* для экранов входа). Поле `version` опционально: если backend его не отдал,
* версию просто не показываем (см. `HealthOut.version`). Ключ запроса тот же
* `['health']`, что и у админки, — react-query отдаст общий закэшированный
* результат, лишнего запроса не будет.
*
* Год — текущий, из `Date` (не захардкожен): страница живёт в браузере
* пользователя и не должна устаревать 1 января.
*/
export function AppFooter() {
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<footer className="app-footer">
VidConf{health?.version ? ` · v${health.version}` : ''} · © {new Date().getFullYear()} · by{' '}
<a href="https://github.com/MaxRonzhin" target="_blank" rel="noopener">
MaxRonzhin
</a>
</footer>
)
}

View File

@@ -1,23 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface DeviceSettingsDialogProps {
onClose: () => void
}
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}`
}
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
function useIsCompactViewport(): boolean {
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
useEffect(() => {
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
return isCompact
}
/**
* Диалог «Настройки устройств» — два селекта
* на хуках `@livekit/components-react`: список устройств и переключение —
* целиком в `useMediaDeviceSelect` (сама подписана на
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
* (localStorage, читается заново при следующем входе в комнату — см.
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
* `deviceId` при следующем подключении.
*
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
* Десктоп не меняется.
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
@@ -29,6 +57,15 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
const speakerSelectable = isAudioOutputSelectable()
const isCompact = useIsCompactViewport()
useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null)
const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false)
async function handleMicChange(deviceId: string) {
try {
@@ -49,15 +86,62 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
}
}
async function handleSpeakerChange(deviceId: string) {
try {
await speaker.setActiveMediaDevice(deviceId)
saveAudioOutputDeviceId(deviceId)
} catch {
toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error')
}
}
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragStartYRef.current = e.touches[0].clientY
setIsDragging(true)
}
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta)
}
function handleHandleTouchEnd() {
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose()
return
}
dragStartYRef.current = null
setIsDragging(false)
setDragOffset(0)
}
return (
<div
className="room-modal-overlay"
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
role="dialog"
aria-modal="true"
aria-labelledby="device-settings-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()}
style={
isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined
}
>
{isCompact && (
<div
className="room-sheet-handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
/>
)}
<div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
@@ -104,6 +188,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
))}
</select>
</div>
{speakerSelectable ? (
<div className="room-field">
<label htmlFor="device-settings-speaker">Динамики</label>
<select
id="device-settings-speaker"
value={speaker.activeDeviceId}
onChange={(e) => void handleSpeakerChange(e.target.value)}
>
<option value="" disabled>
Определяется
</option>
{speaker.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Динамик')}
</option>
))}
</select>
</div>
) : (
<div className="room-field">
<span className="room-field-label">Динамики</span>
<p className="room-field-hint">
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы этот
браузер не позволяет управлять им со страницы.
</p>
</div>
)}
</div>
</div>
)

View File

@@ -1,4 +1,4 @@
import { ScreenShare } from 'lucide-react'
import { Pin, PinOff, ScreenShare } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
@@ -19,6 +19,7 @@ import {
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
@@ -43,7 +44,7 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
const trackReference = useEnsureTrackRef()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -60,6 +61,12 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
)
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
return (
<>
@@ -106,6 +113,25 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
<FocusToggle trackRef={trackReference} />
{onTogglePin && (
<button
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'}
aria-label={
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».
e.stopPropagation()
onTogglePin(tileKey)
}}
>
{isPinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
</button>
)}
{showSharingChip && (
<div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" />
@@ -119,10 +145,8 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
)
}
interface RoomParticipantTileProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
interface TileControlsProps {
/**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
@@ -131,6 +155,23 @@ interface RoomParticipantTileProps {
* в этом приложении не появляется (см. `RoomStage.tsx`).
*/
onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
}
interface RoomParticipantTileProps extends TileControlsProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
}
/**
@@ -142,15 +183,20 @@ interface RoomParticipantTileProps {
* собственного токена), и для удалённых.
*
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext`
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была
* мёртвым кодом без провайдера контекста.
* сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
* в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
* той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
* `pickStageFocus`.
*/
export function RoomParticipantTile({
trackRef,
disableSpeakingIndicator,
onParticipantClick,
onStopSharing,
pinnedKey,
onTogglePin,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
@@ -158,7 +204,7 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} />
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
</ParticipantTile>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Track } from 'livekit-client'
import { useEffect, useState } from 'react'
import { Track, type Participant } from 'livekit-client'
import {
CarouselLayout,
FocusLayoutContainer,
@@ -12,7 +12,7 @@ import {
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
/**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
@@ -39,9 +39,45 @@ const STAGE_TRACK_SOURCES = [
{ source: Track.Source.ScreenShare, withPlaceholder: false },
]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */
function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
/**
* Удержание фокуса основного окна при смене говорящего, мс.
*
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
* реплику длиной в полсекунды он честно отдаёт как смену состава.
*
* Значение подобрано от периода самого события: LiveKit пересчитывает активных
* спикеров примерно раз в 0.5 с, то есть короткая реплика — это 12 обновления.
* 1.2 с ≈ 23 обновления: блик до 1.2 с не проходит вовсе (значение успевает
* вернуться обратно, таймер перезапускается), а осмысленная фраза переключает
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика.
*/
const SPEAKER_HOLD_MS = 1200
/**
* Возвращает состав говорящих, «успокоенный» удержанием: новое значение
* применяется, только если оно продержалось `holdMs` без изменений. Короткая
* реплика меняет состав и возвращает его обратно раньше таймера — тогда
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим).
*
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там
* фокус обязан следовать за говорящим мгновенно, поведение не менялось).
*/
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers)
useEffect(() => {
if (holdMs <= 0 || speakers === steady) return
const timer = setTimeout(() => setSteady(speakers), holdMs)
return () => clearTimeout(timer)
}, [speakers, steady, holdMs])
return holdMs <= 0 ? speakers : steady
}
/**
@@ -75,23 +111,27 @@ function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
*
* Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер
* (`variant="full"`, дефолт) не меняется вовсе.
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида.
*
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
*/
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
const room = useRoomContext()
const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false,
})
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of
// Hooks) — для `variant="full"` его результат просто не используется.
const speakingParticipants = useSpeakingParticipants()
// Активные спикеры уже отсортированы SDK по громкости
// (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers`
// и `SPEAKER_HOLD_MS`), PiP берёт значение как есть.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -121,47 +161,66 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
// новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки).
//
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый
// массив только при реальном изменении состава/порядка говорящих), и
// передаётся `followSpeaker: true` — фокус живо переключается на нового
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
// Пересчёт триггерится ещё и сменой `speakingParticipants` (тоже сравнение
// по ссылке — хук отдаёт новый массив только при реальном изменении состава/
// порядка говорящих), и сменой закрепления (`pinnedKey`) — оба входа
// `pickStageFocus` меняют результат без изменения самих треков.
const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего.
const [pinnedKey, setPinnedKey] = useState<string | null>(null)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey)
const screenShareKeys = screenShareTracks.map(stageTrackKey)
// Закреплённый участник вышел из комнаты (его ключа нет ни среди камер — а
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе.
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants
const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey
if (tracksChanged || speakingChanged) {
if (pinnedKey !== null && !pinnedAlive) {
setPinnedKey(null)
}
if (tracksChanged || speakingChanged || pinnedChanged) {
const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно:
// здесь строго тот же расчёт, что был в основном окне ДО этой правки
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK,
// чтобы followSpeaker не дёргался на каждый чих).
const speakingCameraKeys =
variant === 'pip'
? speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
if (pinnedChanged) setPrevPinnedKey(pinnedKey)
// Говорящие — упорядоченные по громкости камера-ключи: нужен именно
// порядок (взять самого громкого) и именно throttled-источник SDK
// (`useSpeakingParticipants`, в основном окне ещё и с удержанием), чтобы
// followSpeaker не дёргался на каждый чих. Сырой `participant.isSpeaking`,
// на котором основное окно жило до 0.0.6, дребезжит и для слежения за
// спикером не годится.
const speakingCameraKeys = speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey)
const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey),
screenShareKeys: screenShareTracks.map(stageTrackKey),
cameraKeys,
screenShareKeys,
speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo:
variant === 'pip'
? []
: cameraTracks.filter((t) => isTrackReference(t) && !t.publication.isMuted).map(stageTrackKey),
prevKeys,
prevFocusKey: focusKey,
followSpeaker: variant === 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем).
pinnedKey: pinnedAlive ? pinnedKey : null,
followSpeaker: true,
holdScreenShare: variant !== 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
})
if (result.focusKey !== focusKey) {
@@ -192,6 +251,15 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
void room.localParticipant.setScreenShareEnabled(false)
}
/**
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*/
function handleTogglePin(key: string) {
setPinnedKey((prev) => (prev === key ? null : key))
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
@@ -209,17 +277,24 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
<section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? (
<GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile />
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
</GridLayout>
) : (
<FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}>
<RoomParticipantTile />
<RoomParticipantTile pinnedKey={pinnedKey} onTogglePin={handleTogglePin} />
</CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
(см. её исходник), поэтому вместо неё используем свою обёртку
напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
{focusTrack && (
<RoomParticipantTile
trackRef={focusTrack}
onStopSharing={handleStopSharing}
pinnedKey={pinnedKey}
onTogglePin={handleTogglePin}
/>
)}
</FocusLayoutContainer>
)}
<RoomAudioRenderer />

View File

@@ -1,9 +1,11 @@
/**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
*
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы,
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера
* (vitest в проект сознательно не вводим, тестируем вручную).
* Никаких зависимостей от React/DOM/LiveKit SDK в РАНТАЙМЕ — на вход только
* примитивы, на выход тоже примитивы; можно покрыть unit-тестом при появлении
* раннера (vitest в проект сознательно не вводим, тестируем вручную).
* Единственный импорт из SDK — `import type` у `stageTrackKey` (тип стирается
* при компиляции, рантайм-зависимости не добавляет).
*
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
@@ -20,9 +22,21 @@
* однозначность (camera и screen_share одного участника — разные ключи).
*/
import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'
/** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share'
/**
* Ключ трека для `pickStageFocus` — см. обоснование схемы в начале файла.
* Живёт здесь (а не в `RoomStage.tsx`), потому что нужен обеим сторонам:
* сцене — чтобы считать фокус, плитке (`RoomParticipantTile`) — чтобы понять,
* закреплена ли именно она, и каким ключом сообщить о нажатии на «закрепить».
*/
export function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[]
@@ -35,19 +49,51 @@ export interface PickStageFocusInput {
* `followSpeaker`, для живого переключения фокуса.
*/
speakingCameraKeys: readonly string[]
/**
* Подмножество `cameraKeys` с ЖИВЫМ видео (камера включена и не в мьюте) —
* среди нескольких одновременно говорящих такой участник выигрывает у
* говорящего с выключенной камерой: показывать крупно аватар-заглушку, когда
* рядом говорит человек с картинкой, бессмысленно. Не указан — приоритета
* нет, берётся первый (самый громкий) говорящий, как было раньше.
*/
cameraKeysWithVideo?: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null
/**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за
* активным спикером (переключаться сразу, а не удерживать текущий), в
* отличие от основного окна сцены — там держим фокус, даже если заговорил
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` —
* поведение основного окна не меняется.
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на
* плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет.
* Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* демонстрацией экрана») — а когда демонстрация закончилась, фокус
* возвращается именно на закреплённого, а не на того, кто был до неё:
* правило закрепления стоит ВЫШЕ удержания предыдущего фокуса.
* Ключ закреплённого участника, покинувшего комнату, игнорируется (его нет
* ни в `cameraKeys`, ни в `screenShareKeys`) — снимает закрепление вызывающая
* сторона.
*/
pinnedKey?: string | null
/**
* Фокус должен ЖИВО следовать за активным спикером (переключаться сразу, а
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5).
*/
followSpeaker?: boolean
/**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный
* центр разговора, и уводить её из большого окна на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе
* — всегда показывает того, кто говорит, поэтому там `false` (поведение
* PiP не менялось с 0.0.4).
*/
holdScreenShare?: boolean
/**
* Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
@@ -64,6 +110,20 @@ export interface PickStageFocusResult {
kind: StageFocusKind | null
}
/**
* Выбирает говорящего, которого стоит показать крупно: среди живых говорящих
* (упорядоченных по громкости) сначала ищем того, у кого включена камера, и
* только если такого нет — берём самого громкого как есть.
*/
function pickSpeakerKey(
speakingCameraKeys: readonly string[],
cameraKeys: readonly string[],
cameraKeysWithVideo: readonly string[],
): string | null {
const liveSpeakers = speakingCameraKeys.filter((key) => cameraKeys.includes(key))
return liveSpeakers.find((key) => cameraKeysWithVideo.includes(key)) ?? liveSpeakers[0] ?? null
}
/**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
*
@@ -72,24 +132,38 @@ export interface PickStageFocusResult {
* фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ
* переходит на него, даже если текущий фокус ещё жив. В основном окне
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3.
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник
* забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).
* При `followSpeaker: false` этот шаг пропускается — см. правило 5.
* 5. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»
* для PiP, когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если
* указан и жив), иначе первая по порядку.
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»,
* когда никто не говорит — держим предыдущего активного.
* 6. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер (снова с приоритетом камеры),
* иначе `localKey` (если указан и жив), иначе первая по порядку.
*/
export function pickStageFocus({
cameraKeys,
screenShareKeys,
speakingCameraKeys,
cameraKeysWithVideo = [],
prevKeys,
prevFocusKey,
pinnedKey = null,
followSpeaker = false,
holdScreenShare = false,
localKey = null,
}: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
@@ -102,8 +176,25 @@ export function pickStageFocus({
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
}
if (pinnedKey) {
// Закреплена сама демонстрация — она и есть «активная демонстрация»,
// уступать нечему (UI позволяет закрепить любую плитку, включая шэр).
if (screenShareKeys.includes(pinnedKey)) {
return { focusKey: pinnedKey, kind: 'screen_share' }
}
// Закреплена камера: пока в комнате идёт чья-то демонстрация, она
// перебивает закрепление (правило оператора) — идём дальше по списку.
if (cameraKeys.includes(pinnedKey) && screenShareKeys.length === 0) {
return { focusKey: pinnedKey, kind: 'camera' }
}
}
if (holdScreenShare && prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key))
const liveSpeaker = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' }
}
@@ -120,7 +211,7 @@ export function pickStageFocus({
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
}
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key))
const speaking = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (speaking) {
return { focusKey: speaking, kind: 'camera' }
}

View File

@@ -0,0 +1,33 @@
const STORAGE_KEY = 'vidconf-audio-output-device'
/**
* Поддержка выбора устройства аудиовывода — определяем по наличию
* `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще
* (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть.
*/
export function isAudioOutputSelectable(): boolean {
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype
}
/**
* Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices`
* LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера),
* поля для аудиовыхода там нет.
*/
export function loadAudioOutputDeviceId(): string {
try {
return localStorage.getItem(STORAGE_KEY) ?? ''
} catch {
// localStorage недоступен (приватный режим/политики браузера) — без
// сохранённого выбора, устройство по умолчанию.
return ''
}
}
export function saveAudioOutputDeviceId(deviceId: string): void {
try {
localStorage.setItem(STORAGE_KEY, deviceId)
} catch {
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
}
}

View File

@@ -1,11 +1,10 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
import { getHealth } from '@/api/health'
import '@/styles/lobby.css'
import '@/styles/calendar.css'
import '@/styles/admin.css'
@@ -30,10 +29,6 @@ const TABS: { value: AdminTab; label: string }[] = [
*/
export function AdminPage() {
const [tab, setTab] = useState<AdminTab>('conferences')
// Версия инстанса для футера — поле `version` в `/api/health`;
// если его нет в ответе, футер версию просто не показывает
// (см. `HealthOut.version`).
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<div className="admin-page page-shell">
@@ -63,11 +58,8 @@ export function AdminPage() {
{tab === 'users' && <AdminUsersTab />}
{tab === 'teams' && <AdminTeamsTab />}
{tab === 'settings' && <AdminSettingsTab />}
<footer className="admin-footer">
VidConf{health?.version ? ` · v${health.version}` : ''}
</footer>
</main>
<AppFooter />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
@@ -102,6 +103,7 @@ export function CalendarPage() {
/>
</div>
</main>
<AppFooter />
{isFormOpen && (
<ConferenceFormModal

View File

@@ -9,6 +9,7 @@ import {
} from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery'
@@ -409,6 +410,7 @@ export function JoinPage() {
</section>
)}
</main>
<AppFooter />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Link2, User, Video } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMedallion } from '@/components/ui/LogoMark'
import { createConference } from '@/api/conferences'
import { useToast } from '@/components/ui/ToastProvider'
@@ -113,6 +114,7 @@ export function LobbyPage() {
</div>
</div>
</main>
<AppFooter />
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
import { CopyPill } from '@/components/ui/CopyPill'
@@ -212,6 +213,7 @@ export function MyConferencesPage() {
</section>
)}
</main>
<AppFooter />
{editingConference && (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>

View File

@@ -2,6 +2,7 @@ import { useRef, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
@@ -271,6 +272,7 @@ export function ProfilePage() {
</form>
</section>
</main>
<AppFooter />
</div>
)
}

View File

@@ -6,19 +6,21 @@ import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонным. */
function domainMismatchMessage(domain: string): string {
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
/** Текст предупреждения о несовпадении домена почты с эталонными. */
function domainMismatchMessage(domains: string[]): string {
const list = domains.map((domain) => `@${domain}`).join(' или ')
return `Укажите рабочую почту — регистрация доступна только для домена ${list}`
}
/**
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
* Поле «Команда» показывается только если выбор команды включён в
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
* (`registration-options.email_domain`), если в админке включена
* верификация по домену — проверка идёт по blur и при сабмите, backend
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
* Поле «Рабочая почта» дополнительно сверяется с эталонными доменами
* (`registration-options.email_domains` — подходит совпадение с ЛЮБЫМ),
* если в админке включена верификация по домену — проверка идёт по blur и
* при сабмите, backend при включённой верификации и чужом домене отвечает
* 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
@@ -38,16 +40,16 @@ export function RegisterPage() {
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomain = registrationOptions?.email_domain ?? null
const expectedEmailDomains = registrationOptions?.email_domains ?? []
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (!expectedEmailDomain) return null
if (expectedEmailDomains.length === 0) return null
const atIndex = value.lastIndexOf('@')
if (atIndex === -1) return null
const domain = value.slice(atIndex + 1).trim().toLowerCase()
if (domain !== expectedEmailDomain.toLowerCase()) {
return domainMismatchMessage(expectedEmailDomain)
if (!expectedEmailDomains.some((expected) => domain === expected.toLowerCase())) {
return domainMismatchMessage(expectedEmailDomains)
}
return null
}
@@ -68,7 +70,7 @@ export function RegisterPage() {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}

View File

@@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface RoomJoinState {
livekitUrl: string
@@ -163,6 +164,12 @@ export function RoomPage() {
() => ({
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
// (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}),
[userChoices],
)

View File

@@ -168,10 +168,8 @@
.admin-page .settings-grid { grid-template-columns: 1fr; }
}
/* ---------- Футер админки ----------
Версия инстанса из `/api/health` (см. AdminPage.tsx) — до появления поля
на backend строка сокращается до одного «VidConf» без версии. */
.admin-page .admin-footer { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--color-border); font: var(--text-caption); color: var(--color-ink-400); text-align: center; }
/* Футер админки (`.admin-footer`) убран в 0.0.6: вместо него общий для всех
страниц компонент `AppFooter` (см. `styles/footer.css`). */
/* =====================================================================
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким

View File

@@ -27,7 +27,11 @@
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
}
.brand-copy { z-index: 1; }
/* `margin-block: auto` держит блок по центру свободной высоты панели. Нужно
с тех пор, как из панели убрали нижнюю строку копирайта (она переехала в
общий футер): у `.brand-panel` осталось два потомка вместо трёх, и
`justify-content: space-between` иначе прижал бы текст к самому низу. */
.brand-copy { z-index: 1; margin-block: auto; }
.eyebrow {
font: var(--text-caption);
letter-spacing: 0.08em;

View File

@@ -0,0 +1,38 @@
/*
* VidConf — общий футер приложения (см. `components/layout/AppFooter.tsx`).
*
* Файл импортируется самим компонентом, а не страницами: футер стоит в четырёх
* разных раскладках (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`),
* и таскать импорт по всем страницам смысла нет.
*
* Футер — обычный блок в потоке (не `position: fixed`), поэтому контент он
* не перекрывает ни на какой высоте экрана.
*/
.app-footer {
padding: var(--space-5) var(--space-4);
font: var(--text-caption);
color: var(--color-ink-400);
text-align: center;
}
.app-footer a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.app-footer a:hover { color: var(--color-ink-700); }
/* Оболочка страниц (`page-shell`): выравниваем футер по ширине контента
(`main.page-main` — max-width 1440 + боковые отступы) и отделяем линией,
как это делал прежний футер админки. */
.page-shell .app-footer {
max-width: 1440px;
margin: 0 auto;
padding: var(--space-4) var(--space-8) var(--space-6);
border-top: 1px solid var(--color-border);
}
/* Экраны auth: футер — последний элемент правой колонки с формой, поэтому
ограничиваем его шириной самой формы (`.screen-block`, 440px), иначе
центрированная строка «уезжала» бы вправо от неё. */
.form-panel .app-footer { max-width: 440px; width: 100%; padding-inline: 0; margin-top: var(--space-8); }
@media (max-width: 640px) {
.page-shell .app-footer { padding: var(--space-4) var(--space-4) var(--space-5); }
}

View File

@@ -378,6 +378,48 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
font-family: var(--font-body);
}
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
.room-field-label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); }
.room-field-hint { font: var(--text-body); color: var(--color-room-text-secondary); margin: 0; }
/*
* ---------- Шторка «Настройки устройств» на мобильном ----------
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
* смещение пальца и сам решает, закрывать или вернуть панель на место;
* `transition` тут — только пружина возврата, во время самого драга
* компонент подставляет инлайновый `transition: none`).
*/
.room-sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 200;
}
.room-sheet-panel {
background: var(--color-room-surface-raised);
border: 1px solid var(--color-room-tile-border);
border-bottom: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-room-panel);
padding: var(--space-4) var(--space-6) calc(var(--space-6) + env(safe-area-inset-bottom));
width: 100%;
max-height: 80vh;
overflow-y: auto;
transition: transform 160ms ease-out;
}
.room-sheet-handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-room-tile-border);
margin: 0 auto var(--space-4);
touch-action: none;
}
/*
* ---------- Чип «Вы демонстрируете экран» ----------
@@ -416,6 +458,52 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
}
.stage-sharing-chip button:hover { filter: brightness(0.95); }
/*
* ---------- Кнопка закрепления участника (булавка) ----------
* Рендерится внутри `RoomParticipantTile` (см. её `TileBody`), т.е. внутри
* `.lk-participant-tile` — тот у @livekit/components-styles уже
* `position: relative`. Геометрия и логика появления повторяют штатную
* `.lk-focus-toggle-button` той же библиотеки (правый верхний угол, видна по
* наведению) — чтобы плитка выглядела цельно, а не «нашей кнопкой поверх
* чужой». Отличие: у закреплённой плитки булавка видна ВСЕГДА и подсвечена —
* закрепление должно читаться без наведения.
*/
.room-pin-toggle {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 5;
display: flex;
padding: 0.25rem;
border: none;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: rgba(0, 0, 0, 0.5);
color: var(--color-room-text-primary);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition-delay: 0.2s;
}
.room-pin-toggle svg { width: 20px; height: 20px; }
.lk-participant-tile:hover .room-pin-toggle,
.lk-participant-tile:focus-within .room-pin-toggle,
.room-pin-toggle.is-pinned { opacity: 1; transition-delay: 0s; }
.room-pin-toggle.is-pinned { background: var(--color-room-mic-on); color: #10331f; }
/* Тач-устройства (мобильный/планшет): hover нет вовсе — иначе булавка была бы
недоступна. Тулбар комнаты не задет: кнопка живёт на плитке. */
@media (hover: none) {
.room-pin-toggle { opacity: 1; transition-delay: 0s; }
}
/* Метка закреплённой плитки — рамка по контуру. Класс закрепления висит на
кнопке (корневой div плитки рендерит библиотека, свой класс туда не
добавить), поэтому селектор идёт через `:has()`. */
.lk-participant-tile:has(.room-pin-toggle.is-pinned) {
outline: 2px solid var(--color-room-mic-on);
outline-offset: -2px;
}
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder {
flex: 1;

View File

@@ -162,9 +162,16 @@ async def send_invitations_async(
body = "\n".join(body_lines)
backend = create_email_backend(settings)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in recipients:
try:
await backend.send(to=email, subject=subject, body=body, attachments=[attachment])
await backend.send(
to=email,
subject=subject,
body=body,
attachments=[attachment],
reply_to=reply_to,
)
except EmailSendError as exc:
if not exc.retryable:
logger.warning(

View File

@@ -156,9 +156,16 @@ async def notify_session_async(
)
backend = create_email_backend(get_settings())
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in pending:
try:
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body)
await backend.send(
to=email,
subject=subject,
body=text_body,
html_body=html_body,
reply_to=reply_to,
)
except EmailSendError as exc:
if not exc.retryable:
logger.warning(